mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-16 16:08:39 +00:00
Merge pull request #56036 from mihir-kandoi/pg-groupby-reports
fix(postgres): satisfy strict GROUP BY in 12 reports
This commit is contained in:
@@ -3,7 +3,8 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _, qb
|
||||
from frappe.query_builder import Column, functions
|
||||
from frappe.query_builder import functions
|
||||
from frappe.query_builder.custom import ConstantColumn
|
||||
from frappe.utils import add_days, date_diff, flt, get_first_day, get_last_day, getdate, rounded
|
||||
|
||||
from erpnext.accounts.report.financial_statements import get_period_list
|
||||
@@ -300,8 +301,10 @@ class Deferred_Revenue_and_Expense_Report:
|
||||
Get all sales and purchase invoices which has deferred revenue/expense items
|
||||
"""
|
||||
gle = qb.DocType("GL Entry")
|
||||
# column doesn't have an alias option
|
||||
posted = Column("posted")
|
||||
# a literal marker: real GL rows are "posted" (dummy/simulated future entries use "not").
|
||||
# ConstantColumn renders a single-quoted string literal, valid on both backends -- a plain
|
||||
# Column rendered as "posted", which MySQL reads as the string but postgres as an identifier.
|
||||
posted = ConstantColumn("posted").as_("posted")
|
||||
|
||||
if self.filters.type == "Revenue":
|
||||
inv = qb.DocType("Sales Invoice")
|
||||
@@ -327,13 +330,15 @@ class Deferred_Revenue_and_Expense_Report:
|
||||
)
|
||||
.select(
|
||||
inv.name.as_("doc"),
|
||||
inv.posting_date,
|
||||
# non-grouped columns are constant per grouped invoice / invoice item -> Max() keeps the
|
||||
# GROUP BY valid on postgres while returning the same value MySQL picked.
|
||||
functions.Max(inv.posting_date).as_("posting_date"),
|
||||
inv_item.name.as_("item"),
|
||||
inv_item.item_name,
|
||||
inv_item.service_start_date,
|
||||
inv_item.service_end_date,
|
||||
inv_item.base_net_amount,
|
||||
deferred_account_field,
|
||||
functions.Max(inv_item.item_name).as_("item_name"),
|
||||
functions.Max(inv_item.service_start_date).as_("service_start_date"),
|
||||
functions.Max(inv_item.service_end_date).as_("service_end_date"),
|
||||
functions.Max(inv_item.base_net_amount).as_("base_net_amount"),
|
||||
functions.Max(deferred_account_field).as_(deferred_account_field.name),
|
||||
gle.posting_date.as_("gle_posting_date"),
|
||||
functions.Sum(gle.debit).as_("debit"),
|
||||
functions.Sum(gle.credit).as_("credit"),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.query_builder.functions import Max, Sum
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
@@ -43,7 +43,13 @@ def get_data(filters):
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
query = (
|
||||
frappe.qb.from_(gle)
|
||||
.select(gle.voucher_type, gle.voucher_no, Sum(gle.debit).as_("debit"), Sum(gle.credit).as_("credit"))
|
||||
# voucher_type is constant per grouped voucher_no -> Max() keeps the GROUP BY valid on postgres
|
||||
.select(
|
||||
Max(gle.voucher_type).as_("voucher_type"),
|
||||
gle.voucher_no,
|
||||
Sum(gle.debit).as_("debit"),
|
||||
Sum(gle.credit).as_("credit"),
|
||||
)
|
||||
.where(gle.is_cancelled == 0)
|
||||
.groupby(gle.voucher_no)
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import copy
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Coalesce, Sum
|
||||
from frappe.query_builder.functions import Coalesce, Max, Sum
|
||||
from frappe.utils import cint, date_diff, flt, getdate
|
||||
|
||||
|
||||
@@ -44,13 +44,15 @@ def get_data(filters):
|
||||
.on(mr_item.parent == mr.name)
|
||||
.select(
|
||||
mr.name.as_("material_request"),
|
||||
mr.transaction_date.as_("date"),
|
||||
mr_item.schedule_date.as_("required_date"),
|
||||
# non-grouped columns are constant per grouped mr.name / item_code -> Max() keeps the
|
||||
# GROUP BY valid on postgres while returning the same value MySQL picked.
|
||||
Max(mr.transaction_date).as_("date"),
|
||||
Max(mr_item.schedule_date).as_("required_date"),
|
||||
mr_item.item_code.as_("item_code"),
|
||||
Sum(Coalesce(mr_item.qty, 0)).as_("qty"),
|
||||
Sum(Coalesce(mr_item.stock_qty, 0)).as_("stock_qty"),
|
||||
Coalesce(mr_item.uom, "").as_("uom"),
|
||||
Coalesce(mr_item.stock_uom, "").as_("stock_uom"),
|
||||
Max(Coalesce(mr_item.uom, "")).as_("uom"),
|
||||
Max(Coalesce(mr_item.stock_uom, "")).as_("stock_uom"),
|
||||
Sum(Coalesce(mr_item.ordered_qty, 0)).as_("ordered_qty"),
|
||||
Sum(Coalesce(mr_item.received_qty, 0)).as_("received_qty"),
|
||||
(Sum(Coalesce(mr_item.stock_qty, 0)) - Sum(Coalesce(mr_item.received_qty, 0))).as_(
|
||||
@@ -58,9 +60,9 @@ def get_data(filters):
|
||||
),
|
||||
Sum(Coalesce(mr_item.received_qty, 0)).as_("received_qty"),
|
||||
(Sum(Coalesce(mr_item.stock_qty, 0)) - Sum(Coalesce(mr_item.ordered_qty, 0))).as_("qty_to_order"),
|
||||
mr_item.item_name,
|
||||
mr_item.description,
|
||||
mr.company,
|
||||
Max(mr_item.item_name).as_("item_name"),
|
||||
Max(mr_item.description).as_("description"),
|
||||
Max(mr.company).as_("company"),
|
||||
)
|
||||
.where(
|
||||
(mr.material_request_type == "Purchase")
|
||||
@@ -72,7 +74,7 @@ def get_data(filters):
|
||||
|
||||
query = get_conditions(filters, query, mr, mr_item) # add conditional conditions
|
||||
|
||||
query = query.groupby(mr.name, mr_item.item_code).orderby(mr.transaction_date, mr.schedule_date)
|
||||
query = query.groupby(mr.name, mr_item.item_code).orderby(Max(mr.transaction_date), Max(mr.schedule_date))
|
||||
data = query.run(as_dict=True)
|
||||
return data
|
||||
|
||||
|
||||
@@ -86,10 +86,12 @@ class SalesPipelineAnalytics:
|
||||
|
||||
if self.filters.get("range") == "Monthly":
|
||||
self.group_by_period = Month(opp.expected_closing)
|
||||
self.duration = MonthName(opp.expected_closing).as_("month")
|
||||
self.duration_expr = MonthName(opp.expected_closing)
|
||||
self.duration = self.duration_expr.as_("month")
|
||||
else:
|
||||
self.group_by_period = Quarter(opp.expected_closing)
|
||||
self.duration = Quarter(opp.expected_closing).as_("quarter")
|
||||
self.duration_expr = Quarter(opp.expected_closing)
|
||||
self.duration = self.duration_expr.as_("quarter")
|
||||
|
||||
self.pipeline_by = {"Owner": "opportunity_owner", "Sales Stage": "sales_stage"}[
|
||||
self.filters.get("pipeline_by")
|
||||
@@ -101,27 +103,35 @@ class SalesPipelineAnalytics:
|
||||
self.get_fields()
|
||||
|
||||
opp = frappe.qb.DocType("Opportunity")
|
||||
query = frappe.qb.get_query(
|
||||
"Opportunity",
|
||||
filters=self.get_conditions(),
|
||||
ignore_permissions=True,
|
||||
)
|
||||
|
||||
pipeline_field = opp._assign if self.group_by_based_on == "_assign" else opp.sales_stage
|
||||
|
||||
if self.filters.get("based_on") == "Number":
|
||||
# Ask get_query for exactly the grouped columns via `fields`, instead of taking its
|
||||
# default un-grouped "name" select and stripping it. Group by the displayed period
|
||||
# expression too, so postgres accepts MonthName alongside the numeric Month used for
|
||||
# chronological ordering (for Quarterly they're the same expression).
|
||||
self.query_result = (
|
||||
query.select(
|
||||
pipeline_field.as_(self.pipeline_by),
|
||||
frappe.query_builder.functions.Count("*").as_("count"),
|
||||
self.duration,
|
||||
frappe.qb.get_query(
|
||||
"Opportunity",
|
||||
filters=self.get_conditions(),
|
||||
fields=[
|
||||
pipeline_field.as_(self.pipeline_by),
|
||||
frappe.query_builder.functions.Count("*").as_("count"),
|
||||
self.duration,
|
||||
],
|
||||
ignore_permissions=True,
|
||||
)
|
||||
.groupby(pipeline_field, self.group_by_period)
|
||||
.groupby(pipeline_field, self.group_by_period, self.duration_expr)
|
||||
.orderby(self.group_by_period)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
if self.filters.get("based_on") == "Amount":
|
||||
query = frappe.qb.get_query(
|
||||
"Opportunity",
|
||||
filters=self.get_conditions(),
|
||||
ignore_permissions=True,
|
||||
)
|
||||
self.query_result = query.select(
|
||||
pipeline_field.as_(self.pipeline_by),
|
||||
opp.opportunity_amount.as_("amount"),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Floor, IfNull, Sum
|
||||
from frappe.query_builder.functions import Floor, IfNull, Max, Min, Sum
|
||||
from frappe.utils import flt
|
||||
from frappe.utils.data import comma_and
|
||||
from pypika.terms import ExistsCriterion
|
||||
@@ -202,14 +202,15 @@ def get_bom_data(filters):
|
||||
.on(bom_item.item_code == bin.item_code)
|
||||
.select(
|
||||
bom_item.item_code,
|
||||
bom_item.description,
|
||||
bom_item.parent.as_("from_bom_no"),
|
||||
# non-grouped columns are constant per grouped item_code -> Max() keeps the GROUP BY valid
|
||||
Max(bom_item.description).as_("description"),
|
||||
Max(bom_item.parent).as_("from_bom_no"),
|
||||
Sum(bom_item.qty_consumed_per_unit).as_("qty_per_unit"),
|
||||
IfNull(Sum(bin.actual_qty), 0).as_("actual_qty"),
|
||||
)
|
||||
.where((bom_item.parent == filters.get("bom")) & (bom_item.parenttype == "BOM"))
|
||||
.groupby(bom_item.item_code)
|
||||
.orderby(bom_item.idx)
|
||||
.orderby(Min(bom_item.idx))
|
||||
)
|
||||
|
||||
if filters.get("warehouse"):
|
||||
@@ -233,7 +234,9 @@ def get_bom_data(filters):
|
||||
query = query.where(bin.warehouse == filters.get("warehouse"))
|
||||
|
||||
if bom_item_table == "BOM Item":
|
||||
query = query.select(bom_item.bom_no, bom_item.is_phantom_item)
|
||||
query = query.select(
|
||||
Max(bom_item.bom_no).as_("bom_no"), Max(bom_item.is_phantom_item).as_("is_phantom_item")
|
||||
)
|
||||
|
||||
data = query.run(as_dict=True)
|
||||
return explode_phantom_boms(data, filters) if bom_item_table == "BOM Item" else data
|
||||
@@ -312,15 +315,17 @@ def get_producible_fg_items(filters):
|
||||
.on(BOM_ITEM.item_code == bin_subquery.item_code)
|
||||
.select(
|
||||
BOM_ITEM.item_code,
|
||||
BOM_ITEM.description,
|
||||
BOM_ITEM.parent.as_("from_bom_no"),
|
||||
(BOM_ITEM.stock_qty / BOM.quantity).as_("qty_per_unit"),
|
||||
IfNull(bin_subquery.actual_qty, 0).as_("available_qty"),
|
||||
Floor(bin_subquery.actual_qty / ((Sum(BOM_ITEM.stock_qty)) / BOM.quantity)),
|
||||
# Sum() below makes this an aggregate query; the other columns are constant per grouped
|
||||
# item_code -> Max() keeps them valid on postgres with the same value MySQL picked.
|
||||
Max(BOM_ITEM.description).as_("description"),
|
||||
Max(BOM_ITEM.parent).as_("from_bom_no"),
|
||||
Max(BOM_ITEM.stock_qty / BOM.quantity).as_("qty_per_unit"),
|
||||
Max(IfNull(bin_subquery.actual_qty, 0)).as_("available_qty"),
|
||||
Floor(Max(bin_subquery.actual_qty) / ((Sum(BOM_ITEM.stock_qty)) / Max(BOM.quantity))),
|
||||
)
|
||||
.where((BOM_ITEM.parent == filters.get("bom")) & (BOM_ITEM.parenttype == "BOM"))
|
||||
.groupby(BOM_ITEM.item_code)
|
||||
.orderby(BOM_ITEM.idx)
|
||||
.orderby(Min(BOM_ITEM.idx))
|
||||
)
|
||||
|
||||
return query.run(as_list=True)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.query_builder.functions import Max, Sum
|
||||
|
||||
Filters = frappe._dict
|
||||
Row = frappe._dict
|
||||
@@ -29,12 +29,14 @@ def get_data(filters: Filters) -> Data:
|
||||
.inner_join(se)
|
||||
.on(wo.name == se.work_order)
|
||||
.select(
|
||||
wo.name,
|
||||
wo.status,
|
||||
wo.production_item,
|
||||
wo.produced_qty,
|
||||
wo.process_loss_qty,
|
||||
wo.qty.as_("qty_to_manufacture"),
|
||||
# grouped by se.work_order (== wo.name); the work-order columns are constant per group ->
|
||||
# Max() keeps the GROUP BY valid on postgres with the same value.
|
||||
Max(wo.name).as_("name"),
|
||||
Max(wo.status).as_("status"),
|
||||
Max(wo.production_item).as_("production_item"),
|
||||
Max(wo.produced_qty).as_("produced_qty"),
|
||||
Max(wo.process_loss_qty).as_("process_loss_qty"),
|
||||
Max(wo.qty).as_("qty_to_manufacture"),
|
||||
Sum(se.total_incoming_value).as_("total_fg_value"),
|
||||
Sum(se.total_outgoing_value).as_("total_rm_value"),
|
||||
)
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder import Case, CustomFunction
|
||||
from frappe.query_builder.functions import Count, Max, Sum
|
||||
from frappe.query_builder import Case
|
||||
from frappe.query_builder.functions import Count, CurDate, DateDiff, Max, Sum
|
||||
from frappe.utils import cint
|
||||
|
||||
|
||||
@@ -37,9 +37,6 @@ def get_sales_details(doctype):
|
||||
customer = frappe.qb.DocType("Customer")
|
||||
sales_doctype = frappe.qb.DocType(doctype)
|
||||
|
||||
date_diff = CustomFunction("DATEDIFF", ["d1", "d2"])
|
||||
current_date = CustomFunction("CURRENT_DATE", [])
|
||||
|
||||
if doctype == "Sales Order":
|
||||
total_considered = Sum(
|
||||
Case()
|
||||
@@ -55,7 +52,9 @@ def get_sales_details(doctype):
|
||||
date_col = sales_doctype.posting_date
|
||||
|
||||
last_order_date = Max(date_col)
|
||||
days_since_last_order = date_diff(current_date(), last_order_date)
|
||||
# DateDiff is cross-database (DATEDIFF on MariaDB, date subtraction on postgres); CurDate()
|
||||
# renders the bare CURRENT_DATE keyword. Yields the integer number of days.
|
||||
days_since_last_order = DateDiff(CurDate(), last_order_date)
|
||||
|
||||
return (
|
||||
frappe.qb.from_(customer)
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _, qb, query_builder
|
||||
from frappe.query_builder import Criterion, functions
|
||||
from frappe.query_builder import Criterion
|
||||
from frappe.query_builder.functions import Max
|
||||
from frappe.utils.dateutils import getdate
|
||||
|
||||
|
||||
@@ -185,9 +186,6 @@ def get_so_with_invoices(filters):
|
||||
conditions = get_conditions(filters)
|
||||
filter_criterions = build_filter_criterions(filters)
|
||||
|
||||
datediff = query_builder.CustomFunction("DATEDIFF", ["cur_date", "due_date"])
|
||||
ifelse = query_builder.CustomFunction("IF", ["condition", "then", "else"])
|
||||
|
||||
query_so = (
|
||||
qb.from_(so)
|
||||
.join(soi)
|
||||
@@ -199,7 +197,8 @@ def get_so_with_invoices(filters):
|
||||
.select(
|
||||
so.customer,
|
||||
so.transaction_date.as_("submitted"),
|
||||
ifelse(datediff(ps.due_date, functions.CurDate()) < 0, "Overdue", "Unpaid").as_("status"),
|
||||
# CASE + a Python date is portable; MySQL's IF()/DATEDIFF()/CURDATE() don't exist on postgres
|
||||
query_builder.Case().when(ps.due_date < getdate(), "Overdue").else_("Unpaid").as_("status"),
|
||||
ps.payment_term,
|
||||
ps.description,
|
||||
ps.due_date,
|
||||
@@ -230,7 +229,13 @@ def get_so_with_invoices(filters):
|
||||
.on(si.name == sii.parent)
|
||||
.inner_join(soi)
|
||||
.on(soi.name == sii.so_detail)
|
||||
.select(sii.sales_order, sii.parent.as_("invoice"), si.base_grand_total.as_("invoice_amount"))
|
||||
.select(
|
||||
# grouped by the invoice (sii.parent); sales_order is arbitrary per invoice on MySQL and
|
||||
# base_grand_total is constant per invoice -> Max() keeps the GROUP BY postgres-valid.
|
||||
Max(sii.sales_order).as_("sales_order"),
|
||||
sii.parent.as_("invoice"),
|
||||
Max(si.base_grand_total).as_("invoice_amount"),
|
||||
)
|
||||
.where((sii.sales_order.isin([x.name for x in sorders])) & (si.docstatus == 1))
|
||||
.groupby(sii.parent)
|
||||
)
|
||||
|
||||
@@ -137,7 +137,8 @@ def get_stock_ledger_entries_for_batch_no(filters):
|
||||
sle.item_code,
|
||||
sle.warehouse,
|
||||
sle.batch_no,
|
||||
sle.posting_date,
|
||||
# posting_date is constant per voucher_no (grouped) -> Max() is unchanged and postgres-valid
|
||||
fn.Max(sle.posting_date).as_("posting_date"),
|
||||
fn.Sum(sle.actual_qty).as_("actual_qty"),
|
||||
fn.Sum(sle.stock_value_difference).as_("stock_value_difference"),
|
||||
)
|
||||
@@ -182,10 +183,13 @@ def get_stock_ledger_entries_for_batch_bundle(filters):
|
||||
.inner_join(batch_package)
|
||||
.on(batch_package.parent == sle.serial_and_batch_bundle)
|
||||
.select(
|
||||
sle.item_code,
|
||||
sle.warehouse,
|
||||
# item_code/warehouse/posting_date are constant per grouped voucher_no+batch_no+warehouse
|
||||
# (a batch belongs to one item; warehouse mirrors the grouped batch_package.warehouse;
|
||||
# a voucher has one posting_date) -> Max() is unchanged and postgres-valid
|
||||
fn.Max(sle.item_code).as_("item_code"),
|
||||
fn.Max(sle.warehouse).as_("warehouse"),
|
||||
batch_package.batch_no,
|
||||
sle.posting_date,
|
||||
fn.Max(sle.posting_date).as_("posting_date"),
|
||||
fn.Sum(batch_package.qty).as_("actual_qty"),
|
||||
fn.Sum(batch_package.stock_value_difference).as_("stock_value_difference"),
|
||||
)
|
||||
|
||||
@@ -254,11 +254,13 @@ def get_stock_ledger_entries(filters, items):
|
||||
|
||||
|
||||
def get_item_wise_max_posting_datetime(filters, items):
|
||||
"""Get the maximum Stock Ledger Entry name for the given filters and items."""
|
||||
"""Get the latest posting datetime per item+warehouse for the given filters and items."""
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
query = (
|
||||
frappe.qb.from_(sle)
|
||||
.select(sle.item_code, sle.warehouse, sle.name, Max(sle.posting_datetime).as_("posting_datetime"))
|
||||
# `name` was selected but never read by the caller (the join below only uses item_code,
|
||||
# warehouse and posting_datetime); drop it so the GROUP BY is valid on postgres.
|
||||
.select(sle.item_code, sle.warehouse, Max(sle.posting_datetime).as_("posting_datetime"))
|
||||
.where(sle.item_code.isin(items) & (sle.is_cancelled == 0))
|
||||
.groupby(sle.item_code, sle.warehouse)
|
||||
)
|
||||
|
||||
@@ -86,12 +86,14 @@ def get_stock_ledger_data(report_filters, filters):
|
||||
"Stock Ledger Entry",
|
||||
filters=filters,
|
||||
fields=[
|
||||
"name",
|
||||
# name is arbitrary per grouped voucher (many SLEs); posting_date/posting_time are constant
|
||||
# per voucher -> MAX() keeps the GROUP BY valid on postgres with the same values MySQL picked.
|
||||
{"MAX": "name", "as": "name"},
|
||||
"voucher_type",
|
||||
"voucher_no",
|
||||
{"SUM": "stock_value_difference", "as": "stock_value"},
|
||||
"posting_date",
|
||||
"posting_time",
|
||||
{"MAX": "posting_date", "as": "posting_date"},
|
||||
{"MAX": "posting_time", "as": "posting_time"},
|
||||
],
|
||||
group_by="voucher_type, voucher_no",
|
||||
order_by="posting_date ASC, posting_time ASC",
|
||||
@@ -113,10 +115,12 @@ def get_gl_data(report_filters, filters):
|
||||
"GL Entry",
|
||||
filters=filters,
|
||||
fields=[
|
||||
"name",
|
||||
# name is arbitrary per grouped voucher (many GL entries); posting_date is constant per
|
||||
# voucher -> MAX() keeps the GROUP BY valid on postgres with the same values MySQL picked.
|
||||
{"MAX": "name", "as": "name"},
|
||||
"voucher_type",
|
||||
"voucher_no",
|
||||
"posting_date",
|
||||
{"MAX": "posting_date", "as": "posting_date"},
|
||||
{
|
||||
"SUB": [{"SUM": "debit_in_account_currency"}, {"SUM": "credit_in_account_currency"}],
|
||||
"as": "account_value",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.query_builder.functions import Max, Sum
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
@@ -53,8 +53,9 @@ def get_total_stock(filters):
|
||||
else:
|
||||
query = query.select(wh.company).groupby(wh.company)
|
||||
|
||||
query = query.select(item.item_code, item.description, Sum(bin.actual_qty).as_("actual_qty")).groupby(
|
||||
item.item_code
|
||||
)
|
||||
# description is constant per grouped item_code -> Max() keeps the GROUP BY valid on postgres
|
||||
query = query.select(
|
||||
item.item_code, Max(item.description).as_("description"), Sum(bin.actual_qty).as_("actual_qty")
|
||||
).groupby(item.item_code)
|
||||
|
||||
return query.run()
|
||||
|
||||
Reference in New Issue
Block a user