Some checks failed
- Add admin SQL query tool with saved queries, schema explorer presets, and collapsible category sections (dev_tools module) - Add platform debug tool for admin diagnostics - Add loyalty settings page with owner-only access control - Fix loyalty settings owner check (use currentUser instead of window.__userData) - Replace HTTPException with AuthorizationException in loyalty routes - Expand loyalty module with PIN service, Apple Wallet, program management - Improve store login with platform detection and multi-platform support - Update billing feature gates and subscription services - Add store platform sync improvements and remove is_primary column - Add unit tests for loyalty (PIN, points, stamps, program services) - Update i18n translations across dev_tools locales Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
525 lines
14 KiB
Python
525 lines
14 KiB
Python
# 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()
|