refactor(arch): eliminate all cross-module model imports in service layer
Some checks failed
CI / ruff (push) Successful in 9s
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / pytest (push) Has been cancelled

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:
2026-02-27 06:13:15 +01:00
parent e3a52f6536
commit 86e85a98b8
66 changed files with 2242 additions and 1295 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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()

View File

@@ -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
)

View File

@@ -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()

View File

@@ -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()

View File

@@ -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}")

View File

@@ -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