refactor(arch): eliminate all cross-module model imports in service layer
Some checks failed
Some checks failed
Enforce MOD-025/MOD-026 rules: zero top-level cross-module model imports remain in any service file. All 66 files migrated using deferred import patterns (method-body, _get_model() helpers, instance-cached self._Model) and new cross-module service methods in tenancy. Documentation updated with Pattern 6 (deferred imports), migration plan marked complete, and violations status reflects 84→0 service-layer violations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,16 +7,17 @@ unified Order model. All Letzshop orders are stored in the `orders` table
|
||||
with `channel='letzshop'`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.billing.services.subscription_service import subscription_service
|
||||
from app.modules.catalog.models import Product
|
||||
from app.modules.marketplace.models import (
|
||||
LetzshopFulfillmentQueue,
|
||||
LetzshopHistoricalImportJob,
|
||||
@@ -24,11 +25,14 @@ from app.modules.marketplace.models import (
|
||||
MarketplaceImportJob,
|
||||
StoreLetzshopCredentials,
|
||||
)
|
||||
from app.modules.orders.models import Order, OrderItem
|
||||
from app.modules.orders.services.order_service import (
|
||||
order_service as unified_order_service,
|
||||
)
|
||||
from app.modules.tenancy.models import Store
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.modules.catalog.models import Product
|
||||
from app.modules.orders.models import Order, OrderItem
|
||||
from app.modules.tenancy.models import Store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -41,11 +45,19 @@ class OrderNotFoundError(Exception):
|
||||
"""Raised when an order is not found."""
|
||||
|
||||
|
||||
def _get_order_models():
|
||||
"""Deferred import for Order/OrderItem models (orders module)."""
|
||||
from app.modules.orders.models import Order, OrderItem
|
||||
|
||||
return Order, OrderItem
|
||||
|
||||
|
||||
class LetzshopOrderService:
|
||||
"""Service for Letzshop order database operations using unified Order model."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self._Order, self._OrderItem = _get_order_models()
|
||||
|
||||
# =========================================================================
|
||||
# Store Operations
|
||||
@@ -53,7 +65,9 @@ class LetzshopOrderService:
|
||||
|
||||
def get_store(self, store_id: int) -> Store | None:
|
||||
"""Get store by ID."""
|
||||
return self.db.query(Store).filter(Store.id == store_id).first()
|
||||
from app.modules.tenancy.services.store_service import store_service
|
||||
|
||||
return store_service.get_store_by_id_optional(self.db, store_id)
|
||||
|
||||
def get_store_or_raise(self, store_id: int) -> Store:
|
||||
"""Get store by ID or raise StoreNotFoundError."""
|
||||
@@ -73,16 +87,21 @@ class LetzshopOrderService:
|
||||
|
||||
Returns a tuple of (store_overviews, total_count).
|
||||
"""
|
||||
query = self.db.query(Store).filter(Store.is_active == True) # noqa: E712
|
||||
from app.modules.tenancy.services.store_service import store_service as _ss
|
||||
|
||||
all_stores = _ss.list_all_stores(self.db, active_only=True)
|
||||
|
||||
if configured_only:
|
||||
query = query.join(
|
||||
StoreLetzshopCredentials,
|
||||
Store.id == StoreLetzshopCredentials.store_id,
|
||||
)
|
||||
# Filter to stores that have credentials
|
||||
cred_store_ids = {
|
||||
c.store_id
|
||||
for c in self.db.query(StoreLetzshopCredentials.store_id).all()
|
||||
}
|
||||
all_stores = [s for s in all_stores if s.id in cred_store_ids]
|
||||
|
||||
total = query.count()
|
||||
stores = query.order_by(Store.name).offset(skip).limit(limit).all()
|
||||
all_stores.sort(key=lambda s: s.name or "")
|
||||
total = len(all_stores)
|
||||
stores = all_stores[skip : skip + limit]
|
||||
|
||||
store_overviews = []
|
||||
for store in stores:
|
||||
@@ -97,20 +116,20 @@ class LetzshopOrderService:
|
||||
total_orders = 0
|
||||
if credentials:
|
||||
pending_orders = (
|
||||
self.db.query(func.count(Order.id))
|
||||
self.db.query(func.count(self._Order.id))
|
||||
.filter(
|
||||
Order.store_id == store.id,
|
||||
Order.channel == "letzshop",
|
||||
Order.status == "pending",
|
||||
self._Order.store_id == store.id,
|
||||
self._Order.channel == "letzshop",
|
||||
self._Order.status == "pending",
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
total_orders = (
|
||||
self.db.query(func.count(Order.id))
|
||||
self.db.query(func.count(self._Order.id))
|
||||
.filter(
|
||||
Order.store_id == store.id,
|
||||
Order.channel == "letzshop",
|
||||
self._Order.store_id == store.id,
|
||||
self._Order.channel == "letzshop",
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
@@ -143,11 +162,11 @@ class LetzshopOrderService:
|
||||
def get_order(self, store_id: int, order_id: int) -> Order | None:
|
||||
"""Get a Letzshop order by ID for a specific store."""
|
||||
return (
|
||||
self.db.query(Order)
|
||||
self.db.query(self._Order)
|
||||
.filter(
|
||||
Order.id == order_id,
|
||||
Order.store_id == store_id,
|
||||
Order.channel == "letzshop",
|
||||
self._Order.id == order_id,
|
||||
self._Order.store_id == store_id,
|
||||
self._Order.channel == "letzshop",
|
||||
)
|
||||
.first()
|
||||
)
|
||||
@@ -164,11 +183,11 @@ class LetzshopOrderService:
|
||||
) -> Order | None:
|
||||
"""Get a Letzshop order by external shipment ID."""
|
||||
return (
|
||||
self.db.query(Order)
|
||||
self.db.query(self._Order)
|
||||
.filter(
|
||||
Order.store_id == store_id,
|
||||
Order.channel == "letzshop",
|
||||
Order.external_shipment_id == shipment_id,
|
||||
self._Order.store_id == store_id,
|
||||
self._Order.channel == "letzshop",
|
||||
self._Order.external_shipment_id == shipment_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
@@ -176,10 +195,10 @@ class LetzshopOrderService:
|
||||
def get_order_by_id(self, order_id: int) -> Order | None:
|
||||
"""Get a Letzshop order by its database ID."""
|
||||
return (
|
||||
self.db.query(Order)
|
||||
self.db.query(self._Order)
|
||||
.filter(
|
||||
Order.id == order_id,
|
||||
Order.channel == "letzshop",
|
||||
self._Order.id == order_id,
|
||||
self._Order.channel == "letzshop",
|
||||
)
|
||||
.first()
|
||||
)
|
||||
@@ -206,26 +225,26 @@ class LetzshopOrderService:
|
||||
|
||||
Returns a tuple of (orders, total_count).
|
||||
"""
|
||||
query = self.db.query(Order).filter(
|
||||
Order.channel == "letzshop",
|
||||
query = self.db.query(self._Order).filter(
|
||||
self._Order.channel == "letzshop",
|
||||
)
|
||||
|
||||
# Filter by store if specified
|
||||
if store_id is not None:
|
||||
query = query.filter(Order.store_id == store_id)
|
||||
query = query.filter(self._Order.store_id == store_id)
|
||||
|
||||
if status:
|
||||
query = query.filter(Order.status == status)
|
||||
query = query.filter(self._Order.status == status)
|
||||
|
||||
if search:
|
||||
search_term = f"%{search}%"
|
||||
query = query.filter(
|
||||
or_(
|
||||
Order.order_number.ilike(search_term),
|
||||
Order.external_order_number.ilike(search_term),
|
||||
Order.customer_email.ilike(search_term),
|
||||
Order.customer_first_name.ilike(search_term),
|
||||
Order.customer_last_name.ilike(search_term),
|
||||
self._Order.order_number.ilike(search_term),
|
||||
self._Order.external_order_number.ilike(search_term),
|
||||
self._Order.customer_email.ilike(search_term),
|
||||
self._Order.customer_first_name.ilike(search_term),
|
||||
self._Order.customer_last_name.ilike(search_term),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -233,15 +252,15 @@ class LetzshopOrderService:
|
||||
if has_declined_items is True:
|
||||
# Subquery to find orders with declined items
|
||||
declined_order_ids = (
|
||||
self.db.query(OrderItem.order_id)
|
||||
.filter(OrderItem.item_state == "confirmed_unavailable")
|
||||
self.db.query(self._OrderItem.order_id)
|
||||
.filter(self._OrderItem.item_state == "confirmed_unavailable")
|
||||
.subquery()
|
||||
)
|
||||
query = query.filter(Order.id.in_(declined_order_ids))
|
||||
query = query.filter(self._Order.id.in_(declined_order_ids))
|
||||
|
||||
total = query.count()
|
||||
orders = (
|
||||
query.order_by(Order.order_date.desc())
|
||||
query.order_by(self._Order.order_date.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
@@ -260,14 +279,14 @@ class LetzshopOrderService:
|
||||
Dict with counts for each status.
|
||||
"""
|
||||
query = self.db.query(
|
||||
Order.status,
|
||||
func.count(Order.id).label("count"),
|
||||
).filter(Order.channel == "letzshop")
|
||||
self._Order.status,
|
||||
func.count(self._Order.id).label("count"),
|
||||
).filter(self._Order.channel == "letzshop")
|
||||
|
||||
if store_id is not None:
|
||||
query = query.filter(Order.store_id == store_id)
|
||||
query = query.filter(self._Order.store_id == store_id)
|
||||
|
||||
status_counts = query.group_by(Order.status).all()
|
||||
status_counts = query.group_by(self._Order.status).all()
|
||||
|
||||
stats = {
|
||||
"pending": 0,
|
||||
@@ -285,15 +304,15 @@ class LetzshopOrderService:
|
||||
|
||||
# Count orders with declined items
|
||||
declined_query = (
|
||||
self.db.query(func.count(func.distinct(OrderItem.order_id)))
|
||||
.join(Order, OrderItem.order_id == Order.id)
|
||||
self.db.query(func.count(func.distinct(self._OrderItem.order_id)))
|
||||
.join(Order, self._OrderItem.order_id == self._Order.id)
|
||||
.filter(
|
||||
Order.channel == "letzshop",
|
||||
OrderItem.item_state == "confirmed_unavailable",
|
||||
self._Order.channel == "letzshop",
|
||||
self._OrderItem.item_state == "confirmed_unavailable",
|
||||
)
|
||||
)
|
||||
if store_id is not None:
|
||||
declined_query = declined_query.filter(Order.store_id == store_id)
|
||||
declined_query = declined_query.filter(self._Order.store_id == store_id)
|
||||
|
||||
stats["has_declined_items"] = declined_query.scalar() or 0
|
||||
|
||||
@@ -370,10 +389,10 @@ class LetzshopOrderService:
|
||||
if unit_id and unit_state:
|
||||
# Find and update the corresponding order item
|
||||
item = (
|
||||
self.db.query(OrderItem)
|
||||
self.db.query(self._OrderItem)
|
||||
.filter(
|
||||
OrderItem.order_id == order.id,
|
||||
OrderItem.external_item_id == unit_id,
|
||||
self._OrderItem.order_id == order.id,
|
||||
self._OrderItem.external_item_id == unit_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
@@ -413,10 +432,10 @@ class LetzshopOrderService:
|
||||
"""
|
||||
# Find and update the item
|
||||
item = (
|
||||
self.db.query(OrderItem)
|
||||
self.db.query(self._OrderItem)
|
||||
.filter(
|
||||
OrderItem.order_id == order.id,
|
||||
OrderItem.external_item_id == item_id,
|
||||
self._OrderItem.order_id == order.id,
|
||||
self._OrderItem.external_item_id == item_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
@@ -427,8 +446,8 @@ class LetzshopOrderService:
|
||||
|
||||
# Check if all items are now processed
|
||||
all_items = (
|
||||
self.db.query(OrderItem)
|
||||
.filter(OrderItem.order_id == order.id)
|
||||
self.db.query(self._OrderItem)
|
||||
.filter(self._OrderItem.order_id == order.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
@@ -478,13 +497,13 @@ class LetzshopOrderService:
|
||||
) -> list[Order]:
|
||||
"""Get orders that have been confirmed but don't have tracking info."""
|
||||
return (
|
||||
self.db.query(Order)
|
||||
self.db.query(self._Order)
|
||||
.filter(
|
||||
Order.store_id == store_id,
|
||||
Order.channel == "letzshop",
|
||||
Order.status == "processing", # Confirmed orders
|
||||
Order.tracking_number.is_(None),
|
||||
Order.external_shipment_id.isnot(None), # Has shipment ID
|
||||
self._Order.store_id == store_id,
|
||||
self._Order.channel == "letzshop",
|
||||
self._Order.status == "processing", # Confirmed orders
|
||||
self._Order.tracking_number.is_(None),
|
||||
self._Order.external_shipment_id.isnot(None), # Has shipment ID
|
||||
)
|
||||
.limit(limit)
|
||||
.all()
|
||||
@@ -530,8 +549,8 @@ class LetzshopOrderService:
|
||||
def get_order_items(self, order: Order) -> list[OrderItem]:
|
||||
"""Get all items for an order."""
|
||||
return (
|
||||
self.db.query(OrderItem)
|
||||
.filter(OrderItem.order_id == order.id)
|
||||
self.db.query(self._OrderItem)
|
||||
.filter(self._OrderItem.order_id == order.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
@@ -630,9 +649,9 @@ class LetzshopOrderService:
|
||||
store_lookup = {store_id: (store.name if store else None, store.store_code if store else None)}
|
||||
else:
|
||||
# Build lookup for all stores when showing all jobs
|
||||
from app.modules.tenancy.models import Store
|
||||
stores = self.db.query(Store.id, Store.name, Store.store_code).all()
|
||||
store_lookup = {v.id: (v.name, v.store_code) for v in stores}
|
||||
from app.modules.tenancy.services.store_service import store_service
|
||||
all_stores = store_service.list_all_stores(self.db)
|
||||
store_lookup = {s.id: (s.name, s.store_code) for s in all_stores}
|
||||
|
||||
# Historical order imports from letzshop_historical_import_jobs
|
||||
if job_type in (None, "historical_import"):
|
||||
@@ -942,6 +961,8 @@ class LetzshopOrderService:
|
||||
if not gtins:
|
||||
return set(), set()
|
||||
|
||||
from app.modules.catalog.models import Product
|
||||
|
||||
products = (
|
||||
self.db.query(Product)
|
||||
.filter(
|
||||
@@ -969,6 +990,8 @@ class LetzshopOrderService:
|
||||
if not gtins:
|
||||
return {}
|
||||
|
||||
from app.modules.catalog.models import Product
|
||||
|
||||
products = (
|
||||
self.db.query(Product)
|
||||
.filter(
|
||||
@@ -988,51 +1011,51 @@ class LetzshopOrderService:
|
||||
# Count orders by status
|
||||
status_counts = (
|
||||
self.db.query(
|
||||
Order.status,
|
||||
func.count(Order.id).label("count"),
|
||||
self._Order.status,
|
||||
func.count(self._Order.id).label("count"),
|
||||
)
|
||||
.filter(
|
||||
Order.store_id == store_id,
|
||||
Order.channel == "letzshop",
|
||||
self._Order.store_id == store_id,
|
||||
self._Order.channel == "letzshop",
|
||||
)
|
||||
.group_by(Order.status)
|
||||
.group_by(self._Order.status)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Count orders by locale
|
||||
locale_counts = (
|
||||
self.db.query(
|
||||
Order.customer_locale,
|
||||
func.count(Order.id).label("count"),
|
||||
self._Order.customer_locale,
|
||||
func.count(self._Order.id).label("count"),
|
||||
)
|
||||
.filter(
|
||||
Order.store_id == store_id,
|
||||
Order.channel == "letzshop",
|
||||
self._Order.store_id == store_id,
|
||||
self._Order.channel == "letzshop",
|
||||
)
|
||||
.group_by(Order.customer_locale)
|
||||
.group_by(self._Order.customer_locale)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Count orders by country
|
||||
country_counts = (
|
||||
self.db.query(
|
||||
Order.ship_country_iso,
|
||||
func.count(Order.id).label("count"),
|
||||
self._Order.ship_country_iso,
|
||||
func.count(self._Order.id).label("count"),
|
||||
)
|
||||
.filter(
|
||||
Order.store_id == store_id,
|
||||
Order.channel == "letzshop",
|
||||
self._Order.store_id == store_id,
|
||||
self._Order.channel == "letzshop",
|
||||
)
|
||||
.group_by(Order.ship_country_iso)
|
||||
.group_by(self._Order.ship_country_iso)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Total orders
|
||||
total_orders = (
|
||||
self.db.query(func.count(Order.id))
|
||||
self.db.query(func.count(self._Order.id))
|
||||
.filter(
|
||||
Order.store_id == store_id,
|
||||
Order.channel == "letzshop",
|
||||
self._Order.store_id == store_id,
|
||||
self._Order.channel == "letzshop",
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
@@ -1040,10 +1063,10 @@ class LetzshopOrderService:
|
||||
|
||||
# Unique customers
|
||||
unique_customers = (
|
||||
self.db.query(func.count(func.distinct(Order.customer_email)))
|
||||
self.db.query(func.count(func.distinct(self._Order.customer_email)))
|
||||
.filter(
|
||||
Order.store_id == store_id,
|
||||
Order.channel == "letzshop",
|
||||
self._Order.store_id == store_id,
|
||||
self._Order.channel == "letzshop",
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
|
||||
@@ -435,11 +435,10 @@ class LetzshopStoreSyncService:
|
||||
"""
|
||||
import random
|
||||
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.modules.tenancy.models import Merchant, Store
|
||||
from app.modules.tenancy.schemas.store import StoreCreate
|
||||
from app.modules.tenancy.services.admin_service import admin_service
|
||||
from app.modules.tenancy.services.merchant_service import merchant_service
|
||||
from app.modules.tenancy.services.store_service import store_service
|
||||
|
||||
# Get cache entry
|
||||
cache_entry = self.get_cached_store(letzshop_slug)
|
||||
@@ -453,7 +452,7 @@ class LetzshopStoreSyncService:
|
||||
)
|
||||
|
||||
# Verify merchant exists
|
||||
merchant = self.db.query(Merchant).filter(Merchant.id == merchant_id).first()
|
||||
merchant = merchant_service.get_merchant_by_id(self.db, merchant_id)
|
||||
if not merchant:
|
||||
raise SyncError(f"Merchant with ID {merchant_id} not found")
|
||||
|
||||
@@ -461,22 +460,12 @@ class LetzshopStoreSyncService:
|
||||
store_code = letzshop_slug.upper().replace("-", "_")[:20]
|
||||
|
||||
# Check if store code already exists
|
||||
existing = (
|
||||
self.db.query(Store)
|
||||
.filter(func.upper(Store.store_code) == store_code)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
if store_service.is_store_code_taken(self.db, store_code):
|
||||
store_code = f"{store_code[:16]}_{random.randint(100, 999)}" # noqa: SEC042
|
||||
|
||||
# Generate subdomain from slug
|
||||
subdomain = letzshop_slug.lower().replace("_", "-")[:30]
|
||||
existing_subdomain = (
|
||||
self.db.query(Store)
|
||||
.filter(func.lower(Store.subdomain) == subdomain)
|
||||
.first()
|
||||
)
|
||||
if existing_subdomain:
|
||||
if store_service.is_subdomain_taken(self.db, subdomain):
|
||||
subdomain = f"{subdomain[:26]}-{random.randint(100, 999)}" # noqa: SEC042
|
||||
|
||||
# Create store data from cache
|
||||
|
||||
@@ -5,16 +5,21 @@ Service for exporting products to Letzshop CSV format.
|
||||
Generates Google Shopping compatible CSV files for Letzshop marketplace.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.modules.catalog.models import Product
|
||||
from app.modules.marketplace.models import LetzshopSyncLog, MarketplaceProduct
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.modules.catalog.models import Product
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Letzshop CSV columns in order
|
||||
@@ -94,18 +99,20 @@ class LetzshopExportService:
|
||||
CSV string content
|
||||
"""
|
||||
# Query products for this store with their marketplace product data
|
||||
from app.modules.catalog.models import Product as ProductModel
|
||||
|
||||
query = (
|
||||
db.query(Product)
|
||||
.filter(Product.store_id == store_id)
|
||||
db.query(ProductModel)
|
||||
.filter(ProductModel.store_id == store_id)
|
||||
.options(
|
||||
joinedload(Product.marketplace_product).joinedload(
|
||||
joinedload(ProductModel.marketplace_product).joinedload(
|
||||
MarketplaceProduct.translations
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if not include_inactive:
|
||||
query = query.filter(Product.is_active == True)
|
||||
query = query.filter(ProductModel.is_active == True)
|
||||
|
||||
products = query.all()
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# app/services/marketplace_import_job_service.py
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -18,7 +21,9 @@ from app.modules.marketplace.schemas import (
|
||||
MarketplaceImportJobRequest,
|
||||
MarketplaceImportJobResponse,
|
||||
)
|
||||
from app.modules.tenancy.models import Store, User
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.modules.tenancy.models import Store, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -331,4 +336,101 @@ class MarketplaceImportJobService:
|
||||
raise ImportValidationError("Failed to retrieve import errors")
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# Cross-module public API methods
|
||||
# ========================================================================
|
||||
|
||||
def get_import_job_stats(
|
||||
self, db: Session, store_id: int | None = None
|
||||
) -> dict:
|
||||
"""
|
||||
Get import job statistics.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
store_id: Optional store scope
|
||||
|
||||
Returns:
|
||||
Dict with total, pending, completed, failed counts
|
||||
"""
|
||||
from sqlalchemy import func
|
||||
|
||||
base = db.query(func.count(MarketplaceImportJob.id))
|
||||
if store_id is not None:
|
||||
base = base.filter(MarketplaceImportJob.store_id == store_id)
|
||||
|
||||
total = base.scalar() or 0
|
||||
pending = (
|
||||
base.filter(MarketplaceImportJob.status == "pending").scalar() or 0
|
||||
)
|
||||
completed = (
|
||||
base.filter(MarketplaceImportJob.status == "completed").scalar() or 0
|
||||
)
|
||||
failed = (
|
||||
base.filter(MarketplaceImportJob.status == "failed").scalar() or 0
|
||||
)
|
||||
processing = (
|
||||
base.filter(MarketplaceImportJob.status == "processing").scalar() or 0
|
||||
)
|
||||
|
||||
# Count today's imports
|
||||
from datetime import UTC, datetime
|
||||
|
||||
today_start = datetime.now(UTC).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
today_base = db.query(func.count(MarketplaceImportJob.id)).filter(
|
||||
MarketplaceImportJob.created_at >= today_start,
|
||||
)
|
||||
if store_id is not None:
|
||||
today_base = today_base.filter(MarketplaceImportJob.store_id == store_id)
|
||||
today = today_base.scalar() or 0
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"pending": pending,
|
||||
"processing": processing,
|
||||
"completed": completed,
|
||||
"failed": failed,
|
||||
"today": today,
|
||||
}
|
||||
|
||||
def get_total_import_job_count(self, db: Session) -> int:
|
||||
"""
|
||||
Get total count of all import jobs.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Total import job count
|
||||
"""
|
||||
from sqlalchemy import func
|
||||
|
||||
return db.query(func.count(MarketplaceImportJob.id)).scalar() or 0
|
||||
|
||||
def get_import_job_count_by_status(
|
||||
self, db: Session, status: str, store_id: int | None = None
|
||||
) -> int:
|
||||
"""
|
||||
Count import jobs by status.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
status: Job status to count
|
||||
store_id: Optional store scope
|
||||
|
||||
Returns:
|
||||
Count of jobs with given status
|
||||
"""
|
||||
from sqlalchemy import func
|
||||
|
||||
query = db.query(func.count(MarketplaceImportJob.id)).filter(
|
||||
MarketplaceImportJob.status == status
|
||||
)
|
||||
if store_id is not None:
|
||||
query = query.filter(MarketplaceImportJob.store_id == store_id)
|
||||
return query.scalar() or 0
|
||||
|
||||
|
||||
marketplace_import_job_service = MarketplaceImportJobService()
|
||||
|
||||
@@ -54,12 +54,12 @@ class MarketplaceMetricsProvider:
|
||||
MarketplaceImportJob,
|
||||
MarketplaceProduct,
|
||||
)
|
||||
from app.modules.tenancy.models import Store
|
||||
from app.modules.tenancy.services.store_service import store_service
|
||||
|
||||
try:
|
||||
# Get store name for MarketplaceProduct queries
|
||||
# (MarketplaceProduct uses store_name, not store_id)
|
||||
store = db.query(Store).filter(Store.id == store_id).first()
|
||||
store = store_service.get_store_by_id_optional(db, store_id)
|
||||
store_name = store.name if store else ""
|
||||
|
||||
# Staging products
|
||||
@@ -200,18 +200,11 @@ class MarketplaceMetricsProvider:
|
||||
MarketplaceImportJob,
|
||||
MarketplaceProduct,
|
||||
)
|
||||
from app.modules.tenancy.models import StorePlatform
|
||||
from app.modules.tenancy.services.platform_service import platform_service
|
||||
|
||||
try:
|
||||
# Get all store IDs for this platform using StorePlatform junction table
|
||||
store_ids = (
|
||||
db.query(StorePlatform.store_id)
|
||||
.filter(
|
||||
StorePlatform.platform_id == platform_id,
|
||||
StorePlatform.is_active == True,
|
||||
)
|
||||
.subquery()
|
||||
)
|
||||
# Get all store IDs for this platform
|
||||
platform_store_ids = platform_service.get_store_ids_for_platform(db, platform_id)
|
||||
|
||||
# Total staging products (across all stores)
|
||||
# Note: MarketplaceProduct doesn't have direct platform_id link
|
||||
@@ -239,14 +232,14 @@ class MarketplaceMetricsProvider:
|
||||
# Import jobs
|
||||
total_imports = (
|
||||
db.query(MarketplaceImportJob)
|
||||
.filter(MarketplaceImportJob.store_id.in_(store_ids))
|
||||
.filter(MarketplaceImportJob.store_id.in_(platform_store_ids))
|
||||
.count()
|
||||
)
|
||||
|
||||
successful_imports = (
|
||||
db.query(MarketplaceImportJob)
|
||||
.filter(
|
||||
MarketplaceImportJob.store_id.in_(store_ids),
|
||||
MarketplaceImportJob.store_id.in_(platform_store_ids),
|
||||
MarketplaceImportJob.status.in_(["completed", "completed_with_errors"]),
|
||||
)
|
||||
.count()
|
||||
@@ -255,7 +248,7 @@ class MarketplaceMetricsProvider:
|
||||
failed_imports = (
|
||||
db.query(MarketplaceImportJob)
|
||||
.filter(
|
||||
MarketplaceImportJob.store_id.in_(store_ids),
|
||||
MarketplaceImportJob.store_id.in_(platform_store_ids),
|
||||
MarketplaceImportJob.status == "failed",
|
||||
)
|
||||
.count()
|
||||
@@ -264,7 +257,7 @@ class MarketplaceMetricsProvider:
|
||||
pending_imports = (
|
||||
db.query(MarketplaceImportJob)
|
||||
.filter(
|
||||
MarketplaceImportJob.store_id.in_(store_ids),
|
||||
MarketplaceImportJob.store_id.in_(platform_store_ids),
|
||||
MarketplaceImportJob.status == "pending",
|
||||
)
|
||||
.count()
|
||||
@@ -273,7 +266,7 @@ class MarketplaceMetricsProvider:
|
||||
processing_imports = (
|
||||
db.query(MarketplaceImportJob)
|
||||
.filter(
|
||||
MarketplaceImportJob.store_id.in_(store_ids),
|
||||
MarketplaceImportJob.store_id.in_(platform_store_ids),
|
||||
MarketplaceImportJob.status == "processing",
|
||||
)
|
||||
.count()
|
||||
@@ -287,7 +280,7 @@ class MarketplaceMetricsProvider:
|
||||
# Stores with imports
|
||||
stores_with_imports = (
|
||||
db.query(func.count(func.distinct(MarketplaceImportJob.store_id)))
|
||||
.filter(MarketplaceImportJob.store_id.in_(store_ids))
|
||||
.filter(MarketplaceImportJob.store_id.in_(platform_store_ids))
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
@@ -22,7 +22,6 @@ from sqlalchemy import or_
|
||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.modules.inventory.models import Inventory
|
||||
from app.modules.inventory.schemas import (
|
||||
InventoryLocationResponse,
|
||||
InventorySummaryResponse,
|
||||
@@ -416,7 +415,11 @@ class MarketplaceProductService:
|
||||
|
||||
# Delete associated inventory entries if GTIN exists
|
||||
if product.gtin:
|
||||
db.query(Inventory).filter(Inventory.gtin == product.gtin).delete()
|
||||
from app.modules.inventory.services.inventory_service import (
|
||||
inventory_service,
|
||||
)
|
||||
|
||||
inventory_service.delete_inventory_by_gtin(db, product.gtin)
|
||||
|
||||
# Translations will be cascade deleted
|
||||
db.delete(product)
|
||||
@@ -446,9 +449,11 @@ class MarketplaceProductService:
|
||||
"""
|
||||
try:
|
||||
# SVC-005 - Admin/internal function for inventory lookup by GTIN
|
||||
inventory_entries = (
|
||||
db.query(Inventory).filter(Inventory.gtin == gtin).all()
|
||||
) # SVC-005
|
||||
from app.modules.inventory.services.inventory_service import (
|
||||
inventory_service,
|
||||
)
|
||||
|
||||
inventory_entries = inventory_service.get_inventory_by_gtin(db, gtin)
|
||||
if not inventory_entries:
|
||||
return None
|
||||
|
||||
@@ -860,9 +865,9 @@ class MarketplaceProductService:
|
||||
Dict with copied, skipped, failed counts and details
|
||||
"""
|
||||
from app.modules.catalog.models import Product, ProductTranslation
|
||||
from app.modules.tenancy.models import Store
|
||||
from app.modules.tenancy.services.store_service import store_service
|
||||
|
||||
store = db.query(Store).filter(Store.id == store_id).first()
|
||||
store = store_service.get_store_by_id_optional(db, store_id)
|
||||
if not store:
|
||||
from app.modules.tenancy.exceptions import StoreNotFoundException
|
||||
|
||||
@@ -1082,5 +1087,120 @@ class MarketplaceProductService:
|
||||
}
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# Cross-module public API methods
|
||||
# ========================================================================
|
||||
|
||||
def get_staging_product_count(self, db: Session, store_name: str) -> int:
|
||||
"""
|
||||
Count staging products by store name.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
store_name: Store name (marketplace uses store_name, not store_id)
|
||||
|
||||
Returns:
|
||||
Product count
|
||||
"""
|
||||
from sqlalchemy import func
|
||||
|
||||
return (
|
||||
db.query(func.count(MarketplaceProduct.id))
|
||||
.filter(MarketplaceProduct.store_name == store_name)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
def get_distinct_brand_count(self, db: Session) -> int:
|
||||
"""
|
||||
Count distinct brands across all marketplace products.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Number of distinct brands
|
||||
"""
|
||||
from sqlalchemy import func
|
||||
|
||||
return (
|
||||
db.query(func.count(func.distinct(MarketplaceProduct.brand)))
|
||||
.filter(MarketplaceProduct.brand.isnot(None))
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
def get_distinct_category_count(self, db: Session) -> int:
|
||||
"""
|
||||
Count distinct Google product categories.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Number of distinct categories
|
||||
"""
|
||||
from sqlalchemy import func
|
||||
|
||||
return (
|
||||
db.query(
|
||||
func.count(func.distinct(MarketplaceProduct.google_product_category))
|
||||
)
|
||||
.filter(MarketplaceProduct.google_product_category.isnot(None))
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
def get_marketplace_breakdown(self, db: Session) -> list[dict]:
|
||||
"""
|
||||
Get product statistics broken down by marketplace source.
|
||||
|
||||
Returns:
|
||||
List of dicts with marketplace, total_products, unique_stores, unique_brands
|
||||
"""
|
||||
from sqlalchemy import func
|
||||
|
||||
stats = (
|
||||
db.query(
|
||||
MarketplaceProduct.marketplace,
|
||||
func.count(MarketplaceProduct.id).label("total_products"),
|
||||
func.count(func.distinct(MarketplaceProduct.store_name)).label("unique_stores"),
|
||||
func.count(func.distinct(MarketplaceProduct.brand)).label("unique_brands"),
|
||||
)
|
||||
.filter(MarketplaceProduct.marketplace.isnot(None))
|
||||
.group_by(MarketplaceProduct.marketplace)
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{
|
||||
"marketplace": s.marketplace,
|
||||
"total_products": s.total_products,
|
||||
"unique_stores": s.unique_stores,
|
||||
"unique_brands": s.unique_brands,
|
||||
}
|
||||
for s in stats
|
||||
]
|
||||
|
||||
def get_distinct_marketplace_count(self, db: Session) -> int:
|
||||
"""
|
||||
Count distinct marketplace sources.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Number of distinct marketplaces
|
||||
"""
|
||||
from sqlalchemy import func
|
||||
|
||||
return (
|
||||
db.query(func.count(func.distinct(MarketplaceProduct.marketplace)))
|
||||
.filter(MarketplaceProduct.marketplace.isnot(None))
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
# Create service instance
|
||||
marketplace_product_service = MarketplaceProductService()
|
||||
|
||||
@@ -139,22 +139,18 @@ class MarketplaceWidgetProvider:
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.modules.marketplace.models import MarketplaceImportJob
|
||||
from app.modules.tenancy.models import StorePlatform
|
||||
from app.modules.tenancy.services.platform_service import platform_service
|
||||
|
||||
limit = context.limit if context else 5
|
||||
|
||||
# Get store IDs for this platform
|
||||
store_ids_subquery = (
|
||||
db.query(StorePlatform.store_id)
|
||||
.filter(StorePlatform.platform_id == platform_id)
|
||||
.subquery()
|
||||
)
|
||||
# Get store IDs for this platform via platform service
|
||||
store_ids = platform_service.get_store_ids_for_platform(db, platform_id)
|
||||
|
||||
# Get recent imports across all stores in the platform
|
||||
jobs = (
|
||||
db.query(MarketplaceImportJob)
|
||||
.options(joinedload(MarketplaceImportJob.store))
|
||||
.filter(MarketplaceImportJob.store_id.in_(store_ids_subquery))
|
||||
.filter(MarketplaceImportJob.store_id.in_(store_ids))
|
||||
.order_by(MarketplaceImportJob.created_at.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
|
||||
@@ -31,7 +31,6 @@ from app.modules.marketplace.services.letzshop import (
|
||||
LetzshopOrderService,
|
||||
)
|
||||
from app.modules.tenancy.exceptions import StoreNotFoundException
|
||||
from app.modules.tenancy.models import Store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -52,6 +51,12 @@ class OnboardingService:
|
||||
"""
|
||||
self.db = db
|
||||
|
||||
def _get_store(self, store_id: int):
|
||||
"""Get store by ID via store service."""
|
||||
from app.modules.tenancy.services.store_service import store_service
|
||||
|
||||
return store_service.get_store_by_id_optional(self.db, store_id)
|
||||
|
||||
# =========================================================================
|
||||
# Onboarding CRUD
|
||||
# =========================================================================
|
||||
@@ -167,7 +172,7 @@ class OnboardingService:
|
||||
|
||||
def get_merchant_profile_data(self, store_id: int) -> dict:
|
||||
"""Get current merchant profile data for editing."""
|
||||
store = self.db.query(Store).filter(Store.id == store_id).first()
|
||||
store = self._get_store(store_id)
|
||||
if not store:
|
||||
return {}
|
||||
|
||||
@@ -206,7 +211,7 @@ class OnboardingService:
|
||||
Returns response with next step information.
|
||||
"""
|
||||
# Check store exists BEFORE creating onboarding record (FK constraint)
|
||||
store = self.db.query(Store).filter(Store.id == store_id).first()
|
||||
store = self._get_store(store_id)
|
||||
if not store:
|
||||
raise StoreNotFoundException(store_id)
|
||||
|
||||
@@ -346,7 +351,7 @@ class OnboardingService:
|
||||
)
|
||||
|
||||
# Update store with Letzshop identity
|
||||
store = self.db.query(Store).filter(Store.id == store_id).first()
|
||||
store = self._get_store(store_id)
|
||||
if store:
|
||||
store.letzshop_store_slug = shop_slug
|
||||
if letzshop_store_id:
|
||||
@@ -374,7 +379,7 @@ class OnboardingService:
|
||||
|
||||
def get_product_import_config(self, store_id: int) -> dict:
|
||||
"""Get current product import configuration."""
|
||||
store = self.db.query(Store).filter(Store.id == store_id).first()
|
||||
store = self._get_store(store_id)
|
||||
if not store:
|
||||
return {}
|
||||
|
||||
@@ -422,7 +427,7 @@ class OnboardingService:
|
||||
raise OnboardingCsvUrlRequiredException()
|
||||
|
||||
# Update store settings
|
||||
store = self.db.query(Store).filter(Store.id == store_id).first()
|
||||
store = self._get_store(store_id)
|
||||
if not store:
|
||||
raise StoreNotFoundException(store_id)
|
||||
|
||||
@@ -607,7 +612,7 @@ class OnboardingService:
|
||||
self.db.flush()
|
||||
|
||||
# Get store code for redirect URL
|
||||
store = self.db.query(Store).filter(Store.id == store_id).first()
|
||||
store = self._get_store(store_id)
|
||||
store_code = store.store_code if store else ""
|
||||
|
||||
logger.info(f"Completed onboarding for store {store_id}")
|
||||
|
||||
@@ -9,10 +9,13 @@ Handles all database operations for the platform signup flow:
|
||||
- Subscription setup
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -22,10 +25,6 @@ from app.exceptions import (
|
||||
ResourceNotFoundException,
|
||||
ValidationException,
|
||||
)
|
||||
from app.modules.billing.models import (
|
||||
SubscriptionTier,
|
||||
TierCode,
|
||||
)
|
||||
from app.modules.billing.services.stripe_service import stripe_service
|
||||
from app.modules.billing.services.subscription_service import (
|
||||
subscription_service as sub_service,
|
||||
@@ -33,15 +32,11 @@ from app.modules.billing.services.subscription_service import (
|
||||
from app.modules.marketplace.exceptions import OnboardingAlreadyCompletedException
|
||||
from app.modules.marketplace.services.onboarding_service import OnboardingService
|
||||
from app.modules.messaging.services.email_service import EmailService
|
||||
from app.modules.tenancy.models import (
|
||||
Merchant,
|
||||
Platform,
|
||||
Store,
|
||||
StorePlatform,
|
||||
User,
|
||||
)
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.modules.tenancy.models import Store, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -135,6 +130,7 @@ class PlatformSignupService:
|
||||
ValidationException: If tier code is invalid
|
||||
"""
|
||||
# Validate tier code
|
||||
from app.modules.billing.models import TierCode
|
||||
try:
|
||||
tier = TierCode(tier_code)
|
||||
except ValueError:
|
||||
@@ -193,15 +189,9 @@ class PlatformSignupService:
|
||||
|
||||
def check_store_claimed(self, db: Session, letzshop_slug: str) -> bool:
|
||||
"""Check if a Letzshop store is already claimed."""
|
||||
return (
|
||||
db.query(Store)
|
||||
.filter(
|
||||
Store.letzshop_store_slug == letzshop_slug,
|
||||
Store.is_active == True,
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
from app.modules.tenancy.services.store_service import store_service
|
||||
|
||||
return store_service.is_letzshop_slug_claimed(db, letzshop_slug)
|
||||
|
||||
def claim_store(
|
||||
self,
|
||||
@@ -254,35 +244,43 @@ class PlatformSignupService:
|
||||
|
||||
def check_email_exists(self, db: Session, email: str) -> bool:
|
||||
"""Check if an email already exists."""
|
||||
return db.query(User).filter(User.email == email).first() is not None
|
||||
from app.modules.tenancy.services.admin_service import admin_service
|
||||
|
||||
return admin_service.get_user_by_email(db, email) is not None
|
||||
|
||||
def generate_unique_username(self, db: Session, email: str) -> str:
|
||||
"""Generate a unique username from email."""
|
||||
from app.modules.tenancy.services.admin_service import admin_service
|
||||
|
||||
username = email.split("@")[0]
|
||||
base_username = username
|
||||
counter = 1
|
||||
while db.query(User).filter(User.username == username).first():
|
||||
while admin_service.get_user_by_username(db, username):
|
||||
username = f"{base_username}_{counter}"
|
||||
counter += 1
|
||||
return username
|
||||
|
||||
def generate_unique_store_code(self, db: Session, merchant_name: str) -> str:
|
||||
"""Generate a unique store code from merchant name."""
|
||||
from app.modules.tenancy.services.store_service import store_service
|
||||
|
||||
store_code = merchant_name.upper().replace(" ", "_")[:20]
|
||||
base_code = store_code
|
||||
counter = 1
|
||||
while db.query(Store).filter(Store.store_code == store_code).first():
|
||||
while store_service.is_store_code_taken(db, store_code):
|
||||
store_code = f"{base_code}_{counter}"
|
||||
counter += 1
|
||||
return store_code
|
||||
|
||||
def generate_unique_subdomain(self, db: Session, merchant_name: str) -> str:
|
||||
"""Generate a unique subdomain from merchant name."""
|
||||
from app.modules.tenancy.services.store_service import store_service
|
||||
|
||||
subdomain = merchant_name.lower().replace(" ", "-")
|
||||
subdomain = "".join(c for c in subdomain if c.isalnum() or c == "-")[:50]
|
||||
base_subdomain = subdomain
|
||||
counter = 1
|
||||
while db.query(Store).filter(Store.subdomain == subdomain).first():
|
||||
while store_service.is_subdomain_taken(db, subdomain):
|
||||
subdomain = f"{base_subdomain}-{counter}"
|
||||
counter += 1
|
||||
return subdomain
|
||||
@@ -330,6 +328,8 @@ class PlatformSignupService:
|
||||
username = self.generate_unique_username(db, email)
|
||||
|
||||
# Create User
|
||||
from app.modules.tenancy.models import Merchant, Store, User
|
||||
|
||||
user = User(
|
||||
email=email,
|
||||
username=username,
|
||||
@@ -389,11 +389,13 @@ class PlatformSignupService:
|
||||
)
|
||||
|
||||
# Get platform_id for the subscription
|
||||
sp = db.query(StorePlatform.platform_id).filter(StorePlatform.store_id == store.id).first()
|
||||
if sp:
|
||||
platform_id = sp[0]
|
||||
from app.modules.tenancy.services.platform_service import platform_service
|
||||
|
||||
primary_pid = platform_service.get_primary_platform_id_for_store(db, store.id)
|
||||
if primary_pid:
|
||||
platform_id = primary_pid
|
||||
else:
|
||||
default_platform = db.query(Platform).filter(Platform.is_active == True).first()
|
||||
default_platform = platform_service.get_default_platform(db)
|
||||
platform_id = default_platform.id if default_platform else 1
|
||||
|
||||
# Create MerchantSubscription (trial status)
|
||||
@@ -401,7 +403,7 @@ class PlatformSignupService:
|
||||
db=db,
|
||||
merchant_id=merchant.id,
|
||||
platform_id=platform_id,
|
||||
tier_code=session.get("tier_code", TierCode.ESSENTIAL.value),
|
||||
tier_code=session.get("tier_code", "essential"),
|
||||
trial_days=settings.stripe_trial_days,
|
||||
is_annual=session.get("is_annual", False),
|
||||
)
|
||||
@@ -503,7 +505,9 @@ class PlatformSignupService:
|
||||
"""
|
||||
try:
|
||||
# Get tier name
|
||||
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first()
|
||||
from app.modules.billing.services.billing_service import billing_service
|
||||
|
||||
tier = billing_service.get_tier_by_code(db, tier_code)
|
||||
tier_name = tier.name if tier else tier_code.title()
|
||||
|
||||
# Build login URL
|
||||
|
||||
Reference in New Issue
Block a user