v0.1.14 - b2b portal
This commit is contained in:
@@ -3,6 +3,22 @@ from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCos
|
||||
from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission
|
||||
from app.models.mix_calculator import MixCalculatorSession, MixCalculatorSessionLine
|
||||
from app.models.mix import Mix, MixIngredient
|
||||
from app.models.ordering import (
|
||||
CatalogueProduct,
|
||||
CustomerPriceAssignment,
|
||||
CustomerProductPrice,
|
||||
CustomerProductVisibility,
|
||||
NotificationSetting,
|
||||
Order,
|
||||
OrderAttachment,
|
||||
OrderLine,
|
||||
OrderStatusHistory,
|
||||
PriceList,
|
||||
PriceListItem,
|
||||
PriceTier,
|
||||
ProductCategory,
|
||||
XeroSyncLog,
|
||||
)
|
||||
from app.models.product import Product, ProductIngredient
|
||||
from app.models.product_costing import (
|
||||
ProductCostBagInput,
|
||||
@@ -17,14 +33,28 @@ from app.models.scenario import CostingResult, Scenario
|
||||
from app.models.throughput import ProductionThroughput, ThroughputProduct
|
||||
|
||||
__all__ = [
|
||||
"CatalogueProduct",
|
||||
"ClientAccount",
|
||||
"ClientAccessAuditEvent",
|
||||
"ClientFeatureAccess",
|
||||
"ClientUser",
|
||||
"ClientUserModulePermission",
|
||||
"CostingResult",
|
||||
"CustomerPriceAssignment",
|
||||
"CustomerProductPrice",
|
||||
"CustomerProductVisibility",
|
||||
"FreightCostRule",
|
||||
"Mix",
|
||||
"NotificationSetting",
|
||||
"Order",
|
||||
"OrderAttachment",
|
||||
"OrderLine",
|
||||
"OrderStatusHistory",
|
||||
"PriceList",
|
||||
"PriceListItem",
|
||||
"PriceTier",
|
||||
"ProductCategory",
|
||||
"XeroSyncLog",
|
||||
"MixCalculatorSession",
|
||||
"MixCalculatorSessionLine",
|
||||
"MixIngredient",
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
"""B2B ordering portal data model.
|
||||
|
||||
This module backs the private customer ordering portal. It deliberately reuses
|
||||
the existing tenant/customer primitives rather than introducing parallel ones:
|
||||
|
||||
* A **customer/company** is an existing :class:`ClientAccount`.
|
||||
* A **customer user** is an existing :class:`ClientUser` (owner/buyer/viewer/
|
||||
accounts roles map onto the existing ``ordering`` module access levels).
|
||||
|
||||
Everything here is private: every row is tenant-scoped, and customer-facing
|
||||
rows are additionally scoped to a ``client_account_id``. The catalogue itself is
|
||||
global to the seller (one tenant), with per-customer visibility and pricing
|
||||
layered on top.
|
||||
|
||||
Money is stored GST-exclusive (see ``CLAUDE.MD`` costing conventions) as floats,
|
||||
matching the rest of the costing platform.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.session import Base
|
||||
|
||||
# --- Controlled vocabularies -------------------------------------------------
|
||||
# Plain string constants (not DB enums) keep SQLite migrations trivial and match
|
||||
# the existing model style.
|
||||
|
||||
PRODUCT_CATEGORIES = (
|
||||
"grains",
|
||||
"premixed",
|
||||
"bags",
|
||||
"bulk_loads",
|
||||
"custom_blends",
|
||||
"services",
|
||||
)
|
||||
|
||||
# Full internal order lifecycle. Customers see a simplified subset (see
|
||||
# ``CUSTOMER_VISIBLE_STATUS`` in the ordering service).
|
||||
ORDER_STATUSES = (
|
||||
"draft",
|
||||
"submitted",
|
||||
"under_review",
|
||||
"confirmed",
|
||||
"sent_to_xero",
|
||||
"in_production",
|
||||
"ready_for_pickup",
|
||||
"dispatched",
|
||||
"completed",
|
||||
"cancelled",
|
||||
)
|
||||
|
||||
# How a resolved unit price was derived. Stored on each order line so future
|
||||
# reporting can always explain which rule applied.
|
||||
PRICE_SOURCES = (
|
||||
"fixed", # CustomerProductPrice with rule_type fixed
|
||||
"contract", # CustomerProductPrice with rule_type contract
|
||||
"price_list", # PriceListItem via an assigned price list
|
||||
"tiered", # a quantity tier won over the base rate
|
||||
"base", # catalogue list price (optionally with customer discount)
|
||||
"quote", # manual quote required; no automatic price
|
||||
)
|
||||
|
||||
FULFILMENT_METHODS = ("delivery", "pickup")
|
||||
|
||||
|
||||
class ProductCategory(Base):
|
||||
__tablename__ = "product_categories"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_product_category_slug"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||
slug: Mapped[str] = mapped_column(String(64), index=True)
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class CatalogueProduct(Base):
|
||||
"""A private catalogue product. Named ``CatalogueProduct`` to avoid clashing
|
||||
with the existing costing ``Product`` (a separate concern)."""
|
||||
|
||||
__tablename__ = "catalogue_products"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "sku", name="uq_catalogue_product_sku"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), index=True)
|
||||
sku: Mapped[str] = mapped_column(String(64), index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
category: Mapped[str] = mapped_column(String(64), default="grains", index=True)
|
||||
image_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
unit_size: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
unit_of_measure: Mapped[str] = mapped_column(String(64), default="each")
|
||||
min_order_quantity: Mapped[float] = mapped_column(Float, default=1.0)
|
||||
# Base/list price, GST-exclusive. Used as the fallback price source and as
|
||||
# the basis for a customer discount percentage.
|
||||
base_price: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
stock_status: Mapped[str] = mapped_column(String(32), default="in_stock")
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
requires_quote: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class CustomerProductVisibility(Base):
|
||||
"""Per-customer override of catalogue visibility.
|
||||
|
||||
Products are visible to every customer by default (the catalogue is
|
||||
"standard globally"). A row here with ``visible=False`` hides a product from
|
||||
one customer; ``visible=True`` is an explicit allow (no-op unless a future
|
||||
default flips to opt-in).
|
||||
"""
|
||||
|
||||
__tablename__ = "customer_product_visibility"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("client_account_id", "product_id", name="uq_customer_product_visibility"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||
client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True)
|
||||
product_id: Mapped[int] = mapped_column(ForeignKey("catalogue_products.id"), index=True)
|
||||
visible: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class PriceList(Base):
|
||||
__tablename__ = "price_lists"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "code", name="uq_price_list_code"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||
code: Mapped[str] = mapped_column(String(64), index=True)
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
items: Mapped[list["PriceListItem"]] = relationship(
|
||||
back_populates="price_list",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class PriceListItem(Base):
|
||||
__tablename__ = "price_list_items"
|
||||
__table_args__ = (UniqueConstraint("price_list_id", "product_id", name="uq_price_list_item"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||
price_list_id: Mapped[int] = mapped_column(ForeignKey("price_lists.id"), index=True)
|
||||
product_id: Mapped[int] = mapped_column(ForeignKey("catalogue_products.id"), index=True)
|
||||
unit_price: Mapped[float] = mapped_column(Float)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
price_list: Mapped[PriceList] = relationship(back_populates="items")
|
||||
|
||||
|
||||
class CustomerPriceAssignment(Base):
|
||||
"""Links a customer to an assigned price list and a default discount."""
|
||||
|
||||
__tablename__ = "customer_price_assignments"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("client_account_id", name="uq_customer_price_assignment"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||
client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True)
|
||||
price_list_id: Mapped[int | None] = mapped_column(ForeignKey("price_lists.id"), nullable=True, index=True)
|
||||
# Default percentage discount applied to base prices for this customer when
|
||||
# no more specific rule (fixed/contract/price-list) applies. 0..100.
|
||||
discount_percent: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
price_list: Mapped[PriceList | None] = relationship()
|
||||
|
||||
|
||||
class CustomerProductPrice(Base):
|
||||
"""A fixed or contract price for a specific customer + product. Highest
|
||||
priority pricing rule."""
|
||||
|
||||
__tablename__ = "customer_product_prices"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("client_account_id", "product_id", name="uq_customer_product_price"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||
client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True)
|
||||
product_id: Mapped[int] = mapped_column(ForeignKey("catalogue_products.id"), index=True)
|
||||
unit_price: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
# "fixed" | "contract" | "quote" (quote forces a manual-quote workflow for
|
||||
# this customer+product even if the product itself doesn't require one).
|
||||
rule_type: Mapped[str] = mapped_column(String(32), default="fixed")
|
||||
contract_reference: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class PriceTier(Base):
|
||||
"""Quantity break for a pricing source. Exactly one of
|
||||
``customer_product_price_id`` / ``price_list_item_id`` is set."""
|
||||
|
||||
__tablename__ = "price_tiers"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||
customer_product_price_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("customer_product_prices.id"), nullable=True, index=True
|
||||
)
|
||||
price_list_item_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("price_list_items.id"), nullable=True, index=True
|
||||
)
|
||||
# Tier applies when ordered quantity >= min_quantity. The highest qualifying
|
||||
# min_quantity wins.
|
||||
min_quantity: Mapped[float] = mapped_column(Float)
|
||||
unit_price: Mapped[float] = mapped_column(Float)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class Order(Base):
|
||||
__tablename__ = "orders"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||
client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True)
|
||||
# Human-friendly order reference, assigned on submit (e.g. ORD-000123).
|
||||
order_number: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default="draft", index=True)
|
||||
|
||||
# Who created / submitted it (ClientUser ids; nullable for admin-created).
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("client_users.id"), nullable=True)
|
||||
created_by_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
submitted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Customer-supplied order details.
|
||||
purchase_order_number: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
delivery_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
requested_delivery_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
fulfilment_method: Mapped[str] = mapped_column(String(32), default="delivery")
|
||||
|
||||
# Admin/internal handling.
|
||||
admin_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
reopened: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Cached totals (authoritative figures live on lines; this is for listing).
|
||||
subtotal_ex_gst: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
requires_quote: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Xero integration tracking.
|
||||
xero_status: Mapped[str] = mapped_column(String(32), default="not_sent")
|
||||
xero_invoice_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
lines: Mapped[list["OrderLine"]] = relationship(
|
||||
back_populates="order",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="OrderLine.sort_order",
|
||||
)
|
||||
status_history: Mapped[list["OrderStatusHistory"]] = relationship(
|
||||
back_populates="order",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="OrderStatusHistory.created_at",
|
||||
)
|
||||
attachments: Mapped[list["OrderAttachment"]] = relationship(
|
||||
back_populates="order",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class OrderLine(Base):
|
||||
__tablename__ = "order_lines"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||
order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), index=True)
|
||||
product_id: Mapped[int] = mapped_column(ForeignKey("catalogue_products.id"), index=True)
|
||||
|
||||
# Snapshot of product identity at order time (so later catalogue edits don't
|
||||
# rewrite historical orders).
|
||||
product_name: Mapped[str] = mapped_column(String(255))
|
||||
product_sku: Mapped[str] = mapped_column(String(64))
|
||||
|
||||
quantity: Mapped[float] = mapped_column(Float, default=1.0)
|
||||
|
||||
# Authoritative unit price, GST-exclusive, computed by the backend pricing
|
||||
# engine and frozen at submission. Null only while quote-only.
|
||||
unit_price: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
line_total: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
requires_quote: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Pricing provenance — enough to explain which rule applied in reporting.
|
||||
price_source: Mapped[str] = mapped_column(String(32), default="base")
|
||||
price_rule_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
discount_percent: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
|
||||
# Admin override of the resolved unit price before confirmation.
|
||||
admin_override_price: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
admin_override_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
order: Mapped[Order] = relationship(back_populates="lines")
|
||||
|
||||
|
||||
class OrderStatusHistory(Base):
|
||||
__tablename__ = "order_status_history"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||
order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), index=True)
|
||||
from_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
to_status: Mapped[str] = mapped_column(String(32))
|
||||
actor_type: Mapped[str] = mapped_column(String(32), default="system")
|
||||
actor_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
order: Mapped[Order] = relationship(back_populates="status_history")
|
||||
|
||||
|
||||
class OrderAttachment(Base):
|
||||
__tablename__ = "order_attachments"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||
order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), index=True)
|
||||
# "purchase_order" | "confirmation_pdf" | "other"
|
||||
kind: Mapped[str] = mapped_column(String(32), default="other")
|
||||
filename: Mapped[str] = mapped_column(String(255))
|
||||
content_type: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
order: Mapped[Order] = relationship(back_populates="attachments")
|
||||
|
||||
|
||||
class NotificationSetting(Base):
|
||||
"""Per-tenant notification configuration for the ordering portal."""
|
||||
|
||||
__tablename__ = "notification_settings"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", name="uq_notification_settings_tenant"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||
# Comma-separated internal recipients notified on new order submissions.
|
||||
internal_recipients: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
send_customer_confirmation: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
require_po_number: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
from_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class XeroSyncLog(Base):
|
||||
__tablename__ = "xero_sync_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||
order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), index=True)
|
||||
# "queued" | "success" | "failed"
|
||||
status: Mapped[str] = mapped_column(String(32), default="queued")
|
||||
request_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
xero_invoice_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
response_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
Reference in New Issue
Block a user