Files
orion/app/modules/tenancy/services/platform_service.py
Samir Boulahtit 319900623a
Some checks failed
CI / ruff (push) Successful in 14s
CI / pytest (push) Failing after 50m12s
CI / validate (push) Successful in 25s
CI / dependency-scanning (push) Successful in 32s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
feat: add SQL query tool, platform debug, loyalty settings, and multi-module improvements
- 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>
2026-03-10 20:08:07 +01:00

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