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

@@ -13,7 +13,7 @@ import logging
from math import ceil
from sqlalchemy import func
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, joinedload
from app.exceptions import (
BusinessLogicException,
@@ -27,7 +27,6 @@ from app.modules.billing.models import (
SubscriptionStatus,
SubscriptionTier,
)
from app.modules.tenancy.models import Merchant
logger = logging.getLogger(__name__)
@@ -143,8 +142,9 @@ class AdminSubscriptionService:
) -> dict:
"""List merchant subscriptions with filtering and pagination."""
query = (
db.query(MerchantSubscription, Merchant)
.join(Merchant, MerchantSubscription.merchant_id == Merchant.id)
db.query(MerchantSubscription)
.join(MerchantSubscription.merchant)
.options(joinedload(MerchantSubscription.merchant))
)
# Apply filters
@@ -155,20 +155,35 @@ class AdminSubscriptionService:
SubscriptionTier, MerchantSubscription.tier_id == SubscriptionTier.id
).filter(SubscriptionTier.code == tier)
if search:
query = query.filter(Merchant.name.ilike(f"%{search}%"))
from app.modules.tenancy.services.merchant_service import merchant_service
merchants, _ = merchant_service.get_merchants(db, search=search, limit=10000)
merchant_ids = [m.id for m in merchants]
if not merchant_ids:
return {
"results": [],
"total": 0,
"page": page,
"per_page": per_page,
"pages": 0,
}
query = query.filter(MerchantSubscription.merchant_id.in_(merchant_ids))
# Count total
total = query.count()
# Paginate
offset = (page - 1) * per_page
results = (
subs = (
query.order_by(MerchantSubscription.created_at.desc())
.offset(offset)
.limit(per_page)
.all()
)
# Return (sub, merchant) tuples for backward compatibility with callers
results = [(sub, sub.merchant) for sub in subs]
return {
"results": results,
"total": total,
@@ -181,9 +196,9 @@ class AdminSubscriptionService:
self, db: Session, merchant_id: int, platform_id: int
) -> tuple:
"""Get subscription for a specific merchant on a platform."""
result = (
db.query(MerchantSubscription, Merchant)
.join(Merchant, MerchantSubscription.merchant_id == Merchant.id)
sub = (
db.query(MerchantSubscription)
.options(joinedload(MerchantSubscription.merchant))
.filter(
MerchantSubscription.merchant_id == merchant_id,
MerchantSubscription.platform_id == platform_id,
@@ -191,13 +206,13 @@ class AdminSubscriptionService:
.first()
)
if not result:
if not sub:
raise ResourceNotFoundException(
"Subscription",
f"merchant_id={merchant_id}, platform_id={platform_id}",
)
return result
return sub, sub.merchant
def update_subscription(
self, db: Session, merchant_id: int, platform_id: int, update_data: dict
@@ -242,10 +257,7 @@ class AdminSubscriptionService:
status: str | None = None,
) -> dict:
"""List billing history across all merchants."""
query = (
db.query(BillingHistory, Merchant)
.join(Merchant, BillingHistory.merchant_id == Merchant.id)
)
query = db.query(BillingHistory)
if merchant_id:
query = query.filter(BillingHistory.merchant_id == merchant_id)
@@ -255,13 +267,29 @@ class AdminSubscriptionService:
total = query.count()
offset = (page - 1) * per_page
results = (
invoices = (
query.order_by(BillingHistory.invoice_date.desc())
.offset(offset)
.limit(per_page)
.all()
)
# Batch-fetch merchant names for display
from app.modules.tenancy.services.merchant_service import merchant_service
merchant_ids = {inv.merchant_id for inv in invoices if inv.merchant_id}
merchants_map = {}
for mid in merchant_ids:
m = merchant_service.get_merchant_by_id_optional(db, mid)
if m:
merchants_map[mid] = m
# Return (invoice, merchant) tuples for backward compatibility
results = [
(inv, merchants_map.get(inv.merchant_id))
for inv in invoices
]
return {
"results": results,
"total": total,
@@ -276,16 +304,20 @@ class AdminSubscriptionService:
def get_platform_names_map(self, db: Session) -> dict[int, str]:
"""Get mapping of platform_id -> platform_name."""
from app.modules.tenancy.models import Platform
from app.modules.tenancy.services.platform_service import platform_service
return {p.id: p.name for p in db.query(Platform).all()}
platforms = platform_service.list_platforms(db, include_inactive=True)
return {p.id: p.name for p in platforms}
def get_platform_name(self, db: Session, platform_id: int) -> str | None:
"""Get platform name by ID."""
from app.modules.tenancy.models import Platform
from app.modules.tenancy.services.platform_service import platform_service
p = db.query(Platform).filter(Platform.id == platform_id).first()
return p.name if p else None
try:
p = platform_service.get_platform_by_id(db, platform_id)
return p.name
except Exception:
return None
# =========================================================================
# Merchant Subscriptions with Usage
@@ -359,9 +391,9 @@ class AdminSubscriptionService:
Convenience method for admin store detail page. Resolves
store -> merchant -> all platform subscriptions.
"""
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 or not store.merchant_id:
raise ResourceNotFoundException("Store", str(store_id))

View File

@@ -155,8 +155,8 @@ class BillingService:
trial_days = settings.stripe_trial_days
# Get merchant for Stripe customer creation
from app.modules.tenancy.models import Merchant
merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first()
from app.modules.tenancy.services.merchant_service import merchant_service
merchant = merchant_service.get_merchant_by_id_optional(db, merchant_id)
session = stripe_service.create_checkout_session(
db=db,
@@ -494,8 +494,8 @@ class BillingService:
if not addon.stripe_price_id:
raise BillingException(f"Stripe price not configured for add-on '{addon_code}'")
from app.modules.tenancy.models import Store
store = db.query(Store).filter(Store.id == store_id).first()
from app.modules.tenancy.services.store_service import store_service
store = store_service.get_store_by_id_optional(db, store_id)
session = stripe_service.create_checkout_session(
db=db,

View File

@@ -115,21 +115,15 @@ class FeatureService:
Returns:
Tuple of (merchant_id, platform_id), either may be None
"""
from app.modules.tenancy.models import Store, StorePlatform
from app.modules.tenancy.services.platform_service import platform_service
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:
return None, None
merchant_id = store.merchant_id
# Get primary platform_id from StorePlatform junction
sp = (
db.query(StorePlatform.platform_id)
.filter(StorePlatform.store_id == store_id, StorePlatform.is_active == True) # noqa: E712
.order_by(StorePlatform.is_primary.desc())
.first()
)
platform_id = sp[0] if sp else None
platform_id = platform_service.get_primary_platform_id_for_store(db, store_id)
return merchant_id, platform_id
@@ -142,19 +136,14 @@ class FeatureService:
Returns all active platform IDs for the store's merchant,
ordered with the primary platform first.
"""
from app.modules.tenancy.models import Store, StorePlatform
from app.modules.tenancy.services.platform_service import platform_service
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:
return None, []
platform_ids = [
sp[0]
for sp in db.query(StorePlatform.platform_id)
.filter(StorePlatform.store_id == store_id, StorePlatform.is_active == True) # noqa: E712
.order_by(StorePlatform.is_primary.desc())
.all()
]
platform_ids = platform_service.get_active_platform_ids_for_store(db, store_id)
return store.merchant_id, platform_ids
def _get_subscription(

View File

@@ -11,7 +11,8 @@ import logging
from sqlalchemy.orm import Session
from app.modules.tenancy.models import Store, StorePlatform
from app.modules.tenancy.services.platform_service import platform_service
from app.modules.tenancy.services.store_service import store_service
logger = logging.getLogger(__name__)
@@ -34,56 +35,20 @@ class StorePlatformSync:
- Missing + is_active=True → create (set is_primary if store has none)
- Missing + is_active=False → no-op
"""
stores = (
db.query(Store)
.filter(Store.merchant_id == merchant_id)
.all()
)
stores = store_service.get_stores_by_merchant_id(db, merchant_id)
if not stores:
return
for store in stores:
existing = (
db.query(StorePlatform)
.filter(
StorePlatform.store_id == store.id,
StorePlatform.platform_id == platform_id,
)
.first()
result = platform_service.ensure_store_platform(
db, store.id, platform_id, is_active, tier_id
)
if existing:
existing.is_active = is_active
if tier_id is not None:
existing.tier_id = tier_id
if result:
logger.debug(
f"Updated StorePlatform store_id={store.id} "
f"Synced StorePlatform store_id={store.id} "
f"platform_id={platform_id} is_active={is_active}"
)
elif is_active:
# Check if store already has a primary platform
has_primary = (
db.query(StorePlatform)
.filter(
StorePlatform.store_id == store.id,
StorePlatform.is_primary.is_(True),
)
.first()
) is not None
sp = StorePlatform(
store_id=store.id,
platform_id=platform_id,
is_active=True,
is_primary=not has_primary,
tier_id=tier_id,
)
db.add(sp)
logger.info(
f"Created StorePlatform store_id={store.id} "
f"platform_id={platform_id} is_primary={not has_primary}"
)
db.flush()

View File

@@ -10,7 +10,10 @@ Provides:
- Webhook event construction
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
import stripe
from sqlalchemy.orm import Session
@@ -23,7 +26,9 @@ from app.modules.billing.exceptions import (
from app.modules.billing.models import (
MerchantSubscription,
)
from app.modules.tenancy.models import Store
if TYPE_CHECKING:
from app.modules.tenancy.models import Store
logger = logging.getLogger(__name__)
@@ -294,10 +299,10 @@ class StripeService:
self._check_configured()
# Get or create Stripe customer
from app.modules.tenancy.models import StorePlatform
from app.modules.tenancy.services.platform_service import platform_service
from app.modules.tenancy.services.team_service import team_service
sp = db.query(StorePlatform.platform_id).filter(StorePlatform.store_id == store.id).first()
platform_id = sp[0] if sp else None
platform_id = platform_service.get_primary_platform_id_for_store(db, store.id)
subscription = None
if store.merchant_id and platform_id:
subscription = (
@@ -313,16 +318,7 @@ class StripeService:
customer_id = subscription.stripe_customer_id
else:
# Get store owner email
from app.modules.tenancy.models import StoreUser
owner = (
db.query(StoreUser)
.filter(
StoreUser.store_id == store.id,
StoreUser.is_owner == True,
)
.first()
)
owner = team_service.get_store_owner(db, store.id)
email = owner.user.email if owner and owner.user else None
customer_id = self.create_customer(store, email or f"{store.store_code}@placeholder.com")

View File

@@ -53,17 +53,16 @@ class SubscriptionService:
Raises:
ResourceNotFoundException: If store not found or has no platform
"""
from app.modules.tenancy.models import Store, StorePlatform
from app.modules.tenancy.services.platform_service import platform_service
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 or not store.merchant_id:
raise ResourceNotFoundException("Store", str(store_id))
sp = db.query(StorePlatform.platform_id).filter(
StorePlatform.store_id == store_id
).first()
if not sp:
platform_id = platform_service.get_primary_platform_id_for_store(db, store_id)
if not platform_id:
raise ResourceNotFoundException("StorePlatform", f"store_id={store_id}")
return store.merchant_id, sp[0]
return store.merchant_id, platform_id
def get_store_code(self, db: Session, store_id: int) -> str:
"""Get the store_code for a given store_id.
@@ -71,9 +70,9 @@ class SubscriptionService:
Raises:
ResourceNotFoundException: If store not found
"""
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:
raise ResourceNotFoundException("Store", str(store_id))
return store.store_code
@@ -175,9 +174,10 @@ class SubscriptionService:
The merchant subscription, or None if the store, merchant,
or platform cannot be resolved.
"""
from app.modules.tenancy.models import Store
from app.modules.tenancy.services.platform_service import platform_service
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:
return None
@@ -185,17 +185,7 @@ class SubscriptionService:
if merchant_id is None:
return None
# Get platform_id from store
platform_id = getattr(store, "platform_id", None)
if platform_id is None:
from app.modules.tenancy.models import StorePlatform
sp = (
db.query(StorePlatform.platform_id)
.filter(StorePlatform.store_id == store_id)
.first()
)
platform_id = sp[0] if sp else None
platform_id = platform_service.get_primary_platform_id_for_store(db, store_id)
if platform_id is None:
return None
@@ -394,5 +384,60 @@ class SubscriptionService:
return subscription
# =========================================================================
# Cross-module public API methods
# =========================================================================
def get_active_subscription_platform_ids(
self, db: Session, merchant_id: int
) -> list[int]:
"""
Get platform IDs where merchant has active subscriptions.
Args:
db: Database session
merchant_id: Merchant ID
Returns:
List of platform IDs with active subscriptions
"""
active_statuses = [
SubscriptionStatus.ACTIVE,
SubscriptionStatus.TRIAL,
]
results = (
db.query(MerchantSubscription.platform_id)
.filter(
MerchantSubscription.merchant_id == merchant_id,
MerchantSubscription.status.in_(active_statuses),
)
.all()
)
return [r[0] for r in results]
def get_all_active_subscriptions(
self, db: Session
) -> list[MerchantSubscription]:
"""
Get all active/trial subscriptions with tier and feature limits.
Returns:
List of MerchantSubscription objects with eager-loaded tier data
"""
active_statuses = [
SubscriptionStatus.ACTIVE,
SubscriptionStatus.TRIAL,
]
return (
db.query(MerchantSubscription)
.options(
joinedload(MerchantSubscription.tier)
.joinedload(SubscriptionTier.feature_limits),
)
.filter(MerchantSubscription.status.in_(active_statuses))
.all()
)
# Singleton instance
subscription_service = SubscriptionService()

View File

@@ -14,12 +14,10 @@ and feature_service for limit resolution.
import logging
from dataclasses import dataclass
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.modules.billing.models import MerchantSubscription, SubscriptionTier
from app.modules.billing.services.feature_aggregator import feature_aggregator
from app.modules.tenancy.models import StoreUser
logger = logging.getLogger(__name__)
@@ -222,12 +220,9 @@ class UsageService:
def _get_team_member_count(self, db: Session, store_id: int) -> int:
"""Get active team member count for store."""
return (
db.query(func.count(StoreUser.id))
.filter(StoreUser.store_id == store_id, StoreUser.is_active == True) # noqa: E712
.scalar()
or 0
)
from app.modules.tenancy.services.team_service import team_service
return team_service.get_active_team_member_count(db, store_id)
def _calculate_usage_metrics(
self, db: Session, store_id: int, subscription: MerchantSubscription | None