# app/modules/tenancy/services/platform_service.py """ Platform Service Business logic for platform management in the Multi-Platform CMS. Platforms represent different business offerings (OMS, Loyalty, Site Builder, Main Marketing). Each platform has its own: - Marketing pages (homepage, pricing, features) - Store defaults (about, terms, privacy) - Configuration and branding """ import logging from dataclasses import dataclass from sqlalchemy import func from sqlalchemy.orm import Session from app.modules.tenancy.exceptions import ( PlatformNotFoundException, ) from app.modules.tenancy.models import Platform, StorePlatform logger = logging.getLogger(__name__) @dataclass class PlatformStats: """Platform statistics.""" platform_id: int platform_code: str platform_name: str store_count: int platform_pages_count: int store_defaults_count: int store_overrides_count: int = 0 published_pages_count: int = 0 draft_pages_count: int = 0 class PlatformService: """Service for platform operations.""" @staticmethod def get_platform_by_code(db: Session, code: str) -> Platform: """ Get platform by code. Args: db: Database session code: Platform code (oms, loyalty, main, etc.) Returns: Platform object Raises: PlatformNotFoundException: If platform not found """ platform = db.query(Platform).filter(Platform.code == code).first() if not platform: raise PlatformNotFoundException(code) return platform @staticmethod def get_platform_by_code_optional(db: Session, code: str) -> Platform | None: """ Get platform by code, returns None if not found. Args: db: Database session code: Platform code Returns: Platform object or None """ return db.query(Platform).filter(Platform.code == code).first() @staticmethod def get_platform_by_id(db: Session, platform_id: int) -> Platform: """ Get platform by ID. Args: db: Database session platform_id: Platform ID Returns: Platform object Raises: PlatformNotFoundException: If platform not found """ platform = db.query(Platform).filter(Platform.id == platform_id).first() if not platform: raise PlatformNotFoundException(str(platform_id)) return platform @staticmethod def get_default_platform(db: Session) -> Platform | None: """Get the first/default platform.""" return db.query(Platform).first() @staticmethod def list_platforms( db: Session, include_inactive: bool = False ) -> list[Platform]: """ List all platforms. Args: db: Database session include_inactive: Include inactive platforms Returns: List of Platform objects """ query = db.query(Platform) if not include_inactive: query = query.filter(Platform.is_active == True) return query.order_by(Platform.id).all() @staticmethod def get_store_count(db: Session, platform_id: int) -> int: """ Get count of stores on a platform. Args: db: Database session platform_id: Platform ID Returns: Store count """ return ( db.query(func.count(StorePlatform.store_id)) .filter(StorePlatform.platform_id == platform_id) .scalar() or 0 ) @staticmethod def get_active_store_count(db: Session, platform_id: int) -> int: """ Get count of active stores on a platform. Args: db: Database session platform_id: Platform ID Returns: Active store count """ from app.modules.tenancy.models import Store return ( db.query(func.count(StorePlatform.store_id)) .join(Store, Store.id == StorePlatform.store_id) .filter( StorePlatform.platform_id == platform_id, Store.is_active == True, # noqa: E712 ) .scalar() or 0 ) @staticmethod def _get_content_page_model(): """Deferred import for CMS ContentPage model.""" from app.modules.cms.models import ContentPage return ContentPage @staticmethod def get_platform_pages_count(db: Session, platform_id: int) -> int: """ Get count of platform marketing pages. Args: db: Database session platform_id: Platform ID Returns: Platform pages count """ ContentPage = PlatformService._get_content_page_model() return ( db.query(func.count(ContentPage.id)) .filter( ContentPage.platform_id == platform_id, ContentPage.store_id is None, ContentPage.is_platform_page == True, ) .scalar() or 0 ) @staticmethod def get_store_defaults_count(db: Session, platform_id: int) -> int: """ Get count of store default pages. Args: db: Database session platform_id: Platform ID Returns: Store defaults count """ ContentPage = PlatformService._get_content_page_model() return ( db.query(func.count(ContentPage.id)) .filter( ContentPage.platform_id == platform_id, ContentPage.store_id is None, ContentPage.is_platform_page == False, ) .scalar() or 0 ) @staticmethod def get_store_overrides_count(db: Session, platform_id: int) -> int: """ Get count of store override pages. Args: db: Database session platform_id: Platform ID Returns: Store overrides count """ ContentPage = PlatformService._get_content_page_model() return ( db.query(func.count(ContentPage.id)) .filter( ContentPage.platform_id == platform_id, ContentPage.store_id is not None, ) .scalar() or 0 ) @staticmethod def get_published_pages_count(db: Session, platform_id: int) -> int: """ Get count of published pages on a platform. Args: db: Database session platform_id: Platform ID Returns: Published pages count """ ContentPage = PlatformService._get_content_page_model() return ( db.query(func.count(ContentPage.id)) .filter( ContentPage.platform_id == platform_id, ContentPage.is_published == True, ) .scalar() or 0 ) @staticmethod def get_draft_pages_count(db: Session, platform_id: int) -> int: """ Get count of draft pages on a platform. Args: db: Database session platform_id: Platform ID Returns: Draft pages count """ ContentPage = PlatformService._get_content_page_model() return ( db.query(func.count(ContentPage.id)) .filter( ContentPage.platform_id == platform_id, ContentPage.is_published == False, ) .scalar() or 0 ) @classmethod def get_platform_stats(cls, db: Session, platform: Platform) -> PlatformStats: """ Get comprehensive statistics for a platform. Args: db: Database session platform: Platform object Returns: PlatformStats dataclass """ return PlatformStats( platform_id=platform.id, platform_code=platform.code, platform_name=platform.name, store_count=cls.get_store_count(db, platform.id), platform_pages_count=cls.get_platform_pages_count(db, platform.id), store_defaults_count=cls.get_store_defaults_count(db, platform.id), store_overrides_count=cls.get_store_overrides_count(db, platform.id), published_pages_count=cls.get_published_pages_count(db, platform.id), draft_pages_count=cls.get_draft_pages_count(db, platform.id), ) # ======================================================================== # StorePlatform cross-module public API methods # ======================================================================== @staticmethod def get_first_active_platform_id_for_store(db: Session, store_id: int) -> int | None: """ Get the first active platform ID for a store (ordered by joined_at). Used as a fallback when platform_id is not available from JWT context (e.g. background tasks, old tokens). Args: db: Database session store_id: Store ID Returns: Platform ID or None if no platform assigned """ result = ( db.query(StorePlatform.platform_id) .filter( StorePlatform.store_id == store_id, StorePlatform.is_active == True, # noqa: E712 ) .order_by(StorePlatform.joined_at) .first() ) return result[0] if result else None @staticmethod def get_active_platform_ids_for_store(db: Session, store_id: int) -> list[int]: """ Get all active platform IDs for a store. Args: db: Database session store_id: Store ID Returns: List of platform IDs """ results = ( db.query(StorePlatform.platform_id) .filter( StorePlatform.store_id == store_id, StorePlatform.is_active == True, # noqa: E712 ) .order_by(StorePlatform.joined_at) .all() ) return [r[0] for r in results] @staticmethod def get_store_platform_entry( db: Session, store_id: int, platform_id: int ) -> StorePlatform | None: """ Get a specific StorePlatform entry. Args: db: Database session store_id: Store ID platform_id: Platform ID Returns: StorePlatform object or None """ return ( db.query(StorePlatform) .filter( StorePlatform.store_id == store_id, StorePlatform.platform_id == platform_id, ) .first() ) @staticmethod def get_store_ids_for_platform( db: Session, platform_id: int, active_only: bool = True ) -> list[int]: """ Get store IDs subscribed to a platform. Args: db: Database session platform_id: Platform ID active_only: Only return active store-platform links Returns: List of store IDs """ query = db.query(StorePlatform.store_id).filter( StorePlatform.platform_id == platform_id, ) if active_only: query = query.filter(StorePlatform.is_active == True) # noqa: E712 return [r[0] for r in query.all()] @staticmethod def ensure_store_platform( db: Session, store_id: int, platform_id: int, is_active: bool, tier_id: int | None = None, ) -> StorePlatform | None: """ Upsert a StorePlatform entry. If the entry exists, update is_active (and tier_id if provided). If missing and is_active=True, create it. If missing and is_active=False, no-op. Args: db: Database session store_id: Store ID platform_id: Platform ID is_active: Whether the store-platform link is active tier_id: Optional subscription tier ID Returns: The StorePlatform entry, or None if no-op """ existing = ( db.query(StorePlatform) .filter( StorePlatform.store_id == store_id, StorePlatform.platform_id == platform_id, ) .first() ) if existing: existing.is_active = is_active if tier_id is not None: existing.tier_id = tier_id return existing if is_active: sp = StorePlatform( store_id=store_id, platform_id=platform_id, is_active=True, tier_id=tier_id, ) db.add(sp) return sp return None @staticmethod def create_platform(db: Session, data: dict) -> Platform: """ Create a new platform. Note: This method does NOT commit the transaction. The caller (API endpoint) is responsible for committing. Args: db: Database session data: Dictionary of fields for the new platform Returns: Created Platform object (with pending changes) """ platform = Platform() for field, value in data.items(): if hasattr(platform, field): setattr(platform, field, value) db.add(platform) logger.info(f"[PLATFORMS] Created platform: {platform.code}") return platform @staticmethod def update_platform( db: Session, platform: Platform, update_data: dict ) -> Platform: """ Update platform fields. Note: This method does NOT commit the transaction. The caller (API endpoint) is responsible for committing. Args: db: Database session platform: Platform to update update_data: Dictionary of fields to update Returns: Updated Platform object (with pending changes) """ for field, value in update_data.items(): if hasattr(platform, field): setattr(platform, field, value) logger.info(f"[PLATFORMS] Updated platform: {platform.code}") return platform # Singleton instance for convenience platform_service = PlatformService()