From 1a52611438360578f00d8d9c616a4f18eb7103f6 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Tue, 27 Jan 2026 22:02:39 +0100 Subject: [PATCH] feat: implement three-tier module classification and framework layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Module Classification: - Core (4): core, tenancy, cms, customers - always enabled - Optional (7): payments, billing, inventory, orders, marketplace, analytics, messaging - Internal (2): dev-tools, monitoring - admin-only Key Changes: - Rename platform-admin module to tenancy - Promote CMS and Customers to core modules - Create new payments module (gateway abstractions) - Add billing→payments and orders→payments dependencies - Mark dev-tools and monitoring as internal modules New Infrastructure: - app/modules/events.py: Module event bus (ENABLED, DISABLED, STARTUP, SHUTDOWN) - app/modules/migrations.py: Module-specific migration discovery - app/core/observability.py: Health checks, Prometheus metrics, Sentry integration Enhanced ModuleDefinition: - version, is_internal, permissions - config_schema, default_config - migrations_path - Lifecycle hooks: on_enable, on_disable, on_startup, health_check New Registry Functions: - get_optional_module_codes(), get_internal_module_codes() - is_core_module(), is_internal_module() - get_modules_by_tier(), get_module_tier() Migrations: - zc*: Rename platform-admin to tenancy - zd*: Ensure CMS and Customers enabled for all platforms Co-Authored-By: Claude Opus 4.5 --- alembic/env.py | 42 +- ...o5p6q7_rename_platform_admin_to_tenancy.py | 95 +++ ...4o5p6q7r8_promote_cms_customers_to_core.py | 95 +++ app/core/observability.py | 664 ++++++++++++++++++ app/modules/__init__.py | 58 +- app/modules/base.py | 241 ++++++- app/modules/billing/definition.py | 7 +- app/modules/cms/definition.py | 2 +- app/modules/customers/definition.py | 2 +- app/modules/dev_tools/definition.py | 2 + app/modules/events.py | 325 +++++++++ app/modules/migrations.py | 253 +++++++ app/modules/monitoring/definition.py | 6 +- app/modules/orders/definition.py | 7 +- app/modules/payments/__init__.py | 32 + app/modules/payments/definition.py | 79 +++ app/modules/payments/models/__init__.py | 11 + app/modules/payments/routes/__init__.py | 7 + app/modules/payments/routes/admin.py | 44 ++ app/modules/payments/routes/vendor.py | 40 ++ app/modules/payments/schemas/__init__.py | 93 +++ app/modules/payments/services/__init__.py | 13 + .../payments/services/gateway_service.py | 351 +++++++++ .../payments/services/payment_service.py | 232 ++++++ app/modules/registry.py | 170 ++++- ...NOTE_2026-01-27_module-reclassification.md | 280 ++++++++ 26 files changed, 3084 insertions(+), 67 deletions(-) create mode 100644 alembic/versions/zc2m3n4o5p6q7_rename_platform_admin_to_tenancy.py create mode 100644 alembic/versions/zd3n4o5p6q7r8_promote_cms_customers_to_core.py create mode 100644 app/core/observability.py create mode 100644 app/modules/events.py create mode 100644 app/modules/migrations.py create mode 100644 app/modules/payments/__init__.py create mode 100644 app/modules/payments/definition.py create mode 100644 app/modules/payments/models/__init__.py create mode 100644 app/modules/payments/routes/__init__.py create mode 100644 app/modules/payments/routes/admin.py create mode 100644 app/modules/payments/routes/vendor.py create mode 100644 app/modules/payments/schemas/__init__.py create mode 100644 app/modules/payments/services/__init__.py create mode 100644 app/modules/payments/services/gateway_service.py create mode 100644 app/modules/payments/services/payment_service.py create mode 100644 docs/proposals/SESSION_NOTE_2026-01-27_module-reclassification.md diff --git a/alembic/env.py b/alembic/env.py index 3c1d6f56..a9dbe3ed 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -213,6 +213,41 @@ if config.config_file_name is not None: target_metadata = Base.metadata +# ============================================================================ +# MODULE MIGRATIONS DISCOVERY +# ============================================================================ +# Discover migration paths from self-contained modules. +# Each module can have its own migrations/ directory. +# ============================================================================ + +def get_version_locations() -> list[str]: + """ + Get all version locations including module migrations. + + Returns: + List of paths to migration version directories + """ + try: + from app.modules.migrations import get_all_migration_paths + + paths = get_all_migration_paths() + locations = [str(p) for p in paths if p.exists()] + + if len(locations) > 1: + print(f"[ALEMBIC] Found {len(locations)} migration locations:") + for loc in locations: + print(f" - {loc}") + + return locations + except ImportError: + # Fallback if module migrations not available + return ["alembic/versions"] + + +# Get version locations for multi-directory support +version_locations = get_version_locations() + + def run_migrations_offline() -> None: """ Run migrations in 'offline' mode. @@ -229,6 +264,7 @@ def run_migrations_offline() -> None: target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}, + version_locations=version_locations, ) with context.begin_transaction(): @@ -249,7 +285,11 @@ def run_migrations_online() -> None: ) with connectable.connect() as connection: - context.configure(connection=connection, target_metadata=target_metadata) + context.configure( + connection=connection, + target_metadata=target_metadata, + version_locations=version_locations, + ) with context.begin_transaction(): context.run_migrations() diff --git a/alembic/versions/zc2m3n4o5p6q7_rename_platform_admin_to_tenancy.py b/alembic/versions/zc2m3n4o5p6q7_rename_platform_admin_to_tenancy.py new file mode 100644 index 00000000..12598957 --- /dev/null +++ b/alembic/versions/zc2m3n4o5p6q7_rename_platform_admin_to_tenancy.py @@ -0,0 +1,95 @@ +# alembic/versions/zc2m3n4o5p6q7_rename_platform_admin_to_tenancy.py +"""Rename platform-admin module to tenancy. + +Revision ID: zc2m3n4o5p6q7 +Revises: zb1l2m3n4o5p6 +Create Date: 2026-01-27 10:00:00.000000 + +""" + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "zc2m3n4o5p6q7" +down_revision = "zb1l2m3n4o5p6" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Rename platform-admin to tenancy in platform_modules table.""" + # Update module_code in platform_modules junction table + op.execute( + """ + UPDATE platform_modules + SET module_code = 'tenancy' + WHERE module_code = 'platform-admin' + """ + ) + + # Also update any JSON settings that might reference the old module code + # This handles Platform.settings["enabled_modules"] for legacy data + op.execute( + """ + UPDATE platforms + SET settings = jsonb_set( + settings, + '{enabled_modules}', + ( + SELECT COALESCE( + jsonb_agg( + CASE + WHEN elem = 'platform-admin' THEN 'tenancy' + ELSE elem + END + ), + '[]'::jsonb + ) + FROM jsonb_array_elements_text( + COALESCE(settings->'enabled_modules', '[]'::jsonb) + ) AS elem + ) + ) + WHERE settings ? 'enabled_modules' + AND settings->'enabled_modules' @> '"platform-admin"' + """ + ) + + +def downgrade() -> None: + """Revert tenancy back to platform-admin.""" + # Revert module_code in platform_modules junction table + op.execute( + """ + UPDATE platform_modules + SET module_code = 'platform-admin' + WHERE module_code = 'tenancy' + """ + ) + + # Revert JSON settings + op.execute( + """ + UPDATE platforms + SET settings = jsonb_set( + settings, + '{enabled_modules}', + ( + SELECT COALESCE( + jsonb_agg( + CASE + WHEN elem = 'tenancy' THEN 'platform-admin' + ELSE elem + END + ), + '[]'::jsonb + ) + FROM jsonb_array_elements_text( + COALESCE(settings->'enabled_modules', '[]'::jsonb) + ) AS elem + ) + ) + WHERE settings ? 'enabled_modules' + AND settings->'enabled_modules' @> '"tenancy"' + """ + ) diff --git a/alembic/versions/zd3n4o5p6q7r8_promote_cms_customers_to_core.py b/alembic/versions/zd3n4o5p6q7r8_promote_cms_customers_to_core.py new file mode 100644 index 00000000..c040ba64 --- /dev/null +++ b/alembic/versions/zd3n4o5p6q7r8_promote_cms_customers_to_core.py @@ -0,0 +1,95 @@ +# alembic/versions/zd3n4o5p6q7r8_promote_cms_customers_to_core.py +"""Promote CMS and Customers modules to core. + +Revision ID: zd3n4o5p6q7r8 +Revises: zc2m3n4o5p6q7 +Create Date: 2026-01-27 10:10:00.000000 + +This migration ensures that CMS and Customers modules are enabled for all platforms, +since they are now core modules that cannot be disabled. +""" + +from datetime import datetime, timezone + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "zd3n4o5p6q7r8" +down_revision = "zc2m3n4o5p6q7" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Enable CMS and Customers modules for all platforms.""" + connection = op.get_bind() + + # Get all platform IDs + platforms = connection.execute( + sa.text("SELECT id FROM platforms") + ).fetchall() + + now = datetime.now(timezone.utc) + core_modules = ["cms", "customers"] + + for (platform_id,) in platforms: + for module_code in core_modules: + # Check if record exists + existing = connection.execute( + sa.text( + """ + SELECT id FROM platform_modules + WHERE platform_id = :platform_id AND module_code = :module_code + """ + ), + {"platform_id": platform_id, "module_code": module_code}, + ).fetchone() + + if existing: + # Update to enabled + connection.execute( + sa.text( + """ + UPDATE platform_modules + SET is_enabled = true, enabled_at = :now + WHERE platform_id = :platform_id AND module_code = :module_code + """ + ), + {"platform_id": platform_id, "module_code": module_code, "now": now}, + ) + else: + # Insert new enabled record + connection.execute( + sa.text( + """ + INSERT INTO platform_modules (platform_id, module_code, is_enabled, enabled_at, config) + VALUES (:platform_id, :module_code, true, :now, '{}') + """ + ), + {"platform_id": platform_id, "module_code": module_code, "now": now}, + ) + + # Also update JSON settings to include CMS and Customers if not present + for module_code in core_modules: + op.execute( + f""" + UPDATE platforms + SET settings = jsonb_set( + COALESCE(settings, '{{}}'::jsonb), + '{{enabled_modules}}', + COALESCE(settings->'enabled_modules', '[]'::jsonb) || '"{module_code}"'::jsonb + ) + WHERE settings ? 'enabled_modules' + AND NOT (settings->'enabled_modules' @> '"{module_code}"') + """ + ) + + +def downgrade() -> None: + """ + Note: This doesn't actually disable CMS and Customers since that would + break functionality. It just removes the explicit enabling done by upgrade. + """ + # No-op: We don't want to disable core modules + pass diff --git a/app/core/observability.py b/app/core/observability.py new file mode 100644 index 00000000..f5ae0f29 --- /dev/null +++ b/app/core/observability.py @@ -0,0 +1,664 @@ +# app/core/observability.py +""" +Observability framework for the platform. + +Provides infrastructure-level monitoring and health check aggregation: +- Prometheus metrics registry and /metrics endpoint +- Health check aggregation from modules +- Sentry error tracking initialization +- External tool integration (Flower, Grafana) + +This is part of the Framework Layer - infrastructure that modules depend on, +not a module itself. + +Usage: + # In main.py lifespan + from app.core.observability import init_observability, shutdown_observability + + @asynccontextmanager + async def lifespan(app: FastAPI): + init_observability() + yield + shutdown_observability() + + # Register health endpoint + from app.core.observability import health_router + app.include_router(health_router) +""" + +import logging +import time +from collections.abc import Callable +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import Enum +from typing import Any + +from fastapi import APIRouter, Response + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# Health Check Types +# ============================================================================= + + +class HealthStatus(str, Enum): + """Health status levels.""" + + HEALTHY = "healthy" + DEGRADED = "degraded" + UNHEALTHY = "unhealthy" + + +@dataclass +class HealthCheckResult: + """Result of a health check.""" + + name: str + status: HealthStatus + message: str = "" + latency_ms: float = 0.0 + details: dict[str, Any] = field(default_factory=dict) + checked_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + +@dataclass +class AggregatedHealth: + """Aggregated health status from all checks.""" + + status: HealthStatus + checks: list[HealthCheckResult] + timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for JSON response.""" + return { + "status": self.status.value, + "timestamp": self.timestamp.isoformat(), + "checks": [ + { + "name": check.name, + "status": check.status.value, + "message": check.message, + "latency_ms": round(check.latency_ms, 2), + "details": check.details, + } + for check in self.checks + ], + } + + +# ============================================================================= +# Health Check Registry +# ============================================================================= + + +class HealthCheckRegistry: + """ + Registry for health check functions. + + Components register their health checks here, and the /health endpoint + aggregates all results. + + Example: + @health_registry.register("database") + def check_database() -> HealthCheckResult: + try: + db.execute("SELECT 1") + return HealthCheckResult(name="database", status=HealthStatus.HEALTHY) + except Exception as e: + return HealthCheckResult( + name="database", + status=HealthStatus.UNHEALTHY, + message=str(e) + ) + """ + + def __init__(self) -> None: + self._checks: dict[str, Callable[[], HealthCheckResult]] = {} + + def register( + self, + name: str, + ) -> Callable[[Callable[[], HealthCheckResult]], Callable[[], HealthCheckResult]]: + """ + Decorator to register a health check. + + Args: + name: Name of the health check + + Returns: + Decorator function + """ + + def decorator( + func: Callable[[], HealthCheckResult], + ) -> Callable[[], HealthCheckResult]: + self._checks[name] = func + logger.debug(f"Registered health check: {name}") + return func + + return decorator + + def register_check( + self, + name: str, + check: Callable[[], HealthCheckResult], + ) -> None: + """ + Register a health check function directly. + + Args: + name: Name of the health check + check: Health check function + """ + self._checks[name] = check + logger.debug(f"Registered health check: {name}") + + def unregister(self, name: str) -> bool: + """ + Unregister a health check. + + Args: + name: Name of the health check to remove + + Returns: + True if check was found and removed + """ + if name in self._checks: + del self._checks[name] + return True + return False + + def run_all(self) -> AggregatedHealth: + """ + Run all registered health checks and aggregate results. + + Returns: + Aggregated health status + """ + results: list[HealthCheckResult] = [] + overall_status = HealthStatus.HEALTHY + + for name, check_func in self._checks.items(): + start = time.perf_counter() + try: + result = check_func() + result.latency_ms = (time.perf_counter() - start) * 1000 + except Exception as e: + result = HealthCheckResult( + name=name, + status=HealthStatus.UNHEALTHY, + message=f"Check failed: {e}", + latency_ms=(time.perf_counter() - start) * 1000, + ) + logger.exception(f"Health check {name} failed") + + results.append(result) + + # Determine overall status (worst wins) + if result.status == HealthStatus.UNHEALTHY: + overall_status = HealthStatus.UNHEALTHY + elif result.status == HealthStatus.DEGRADED and overall_status == HealthStatus.HEALTHY: + overall_status = HealthStatus.DEGRADED + + return AggregatedHealth(status=overall_status, checks=results) + + def get_check_names(self) -> list[str]: + """Get names of all registered checks.""" + return list(self._checks.keys()) + + +# Global health check registry +health_registry = HealthCheckRegistry() + + +# ============================================================================= +# Prometheus Metrics (Placeholder) +# ============================================================================= + + +class MetricsRegistry: + """ + Prometheus metrics registry. + + This is a placeholder implementation. For production, integrate with + prometheus_client library. + + Example: + from prometheus_client import Counter, Histogram + + request_count = metrics_registry.counter( + "http_requests_total", + "Total HTTP requests", + ["method", "endpoint", "status"] + ) + """ + + def __init__(self) -> None: + self._metrics: dict[str, Any] = {} + self._enabled = False + + def enable(self) -> None: + """Enable metrics collection.""" + self._enabled = True + logger.info("Prometheus metrics enabled") + + def disable(self) -> None: + """Disable metrics collection.""" + self._enabled = False + + def counter( + self, + name: str, + description: str, + labels: list[str] | None = None, + ) -> Any: + """ + Create a counter metric. + + Args: + name: Metric name + description: Metric description + labels: Label names + + Returns: + Counter metric (or placeholder if prometheus_client not installed) + """ + if not self._enabled: + return _DummyMetric() + + try: + from prometheus_client import Counter + + metric = Counter(name, description, labels or []) + self._metrics[name] = metric + return metric + except ImportError: + return _DummyMetric() + + def histogram( + self, + name: str, + description: str, + labels: list[str] | None = None, + buckets: list[float] | None = None, + ) -> Any: + """ + Create a histogram metric. + + Args: + name: Metric name + description: Metric description + labels: Label names + buckets: Histogram buckets + + Returns: + Histogram metric (or placeholder) + """ + if not self._enabled: + return _DummyMetric() + + try: + from prometheus_client import Histogram + + kwargs: dict[str, Any] = {} + if buckets: + kwargs["buckets"] = buckets + + metric = Histogram(name, description, labels or [], **kwargs) + self._metrics[name] = metric + return metric + except ImportError: + return _DummyMetric() + + def gauge( + self, + name: str, + description: str, + labels: list[str] | None = None, + ) -> Any: + """ + Create a gauge metric. + + Args: + name: Metric name + description: Metric description + labels: Label names + + Returns: + Gauge metric (or placeholder) + """ + if not self._enabled: + return _DummyMetric() + + try: + from prometheus_client import Gauge + + metric = Gauge(name, description, labels or []) + self._metrics[name] = metric + return metric + except ImportError: + return _DummyMetric() + + def generate_latest(self) -> bytes: + """ + Generate Prometheus metrics output. + + Returns: + Prometheus metrics in text format + """ + if not self._enabled: + return b"# Metrics not enabled\n" + + try: + from prometheus_client import generate_latest + + return generate_latest() + except ImportError: + return b"# prometheus_client not installed\n" + + +class _DummyMetric: + """Placeholder metric when prometheus_client is not available.""" + + def labels(self, *args: Any, **kwargs: Any) -> "_DummyMetric": + return self + + def inc(self, amount: float = 1) -> None: + pass + + def dec(self, amount: float = 1) -> None: + pass + + def set(self, value: float) -> None: + pass + + def observe(self, amount: float) -> None: + pass + + +# Global metrics registry +metrics_registry = MetricsRegistry() + + +# ============================================================================= +# Sentry Integration (Placeholder) +# ============================================================================= + + +class SentryIntegration: + """ + Sentry error tracking integration. + + This is a placeholder. For production, configure with actual Sentry DSN. + + Example: + sentry.init(dsn="https://key@sentry.io/project") + sentry.capture_exception(error) + """ + + def __init__(self) -> None: + self._initialized = False + self._dsn: str | None = None + + def init( + self, + dsn: str | None = None, + environment: str = "development", + release: str | None = None, + ) -> None: + """ + Initialize Sentry SDK. + + Args: + dsn: Sentry DSN + environment: Environment name + release: Release version + """ + if not dsn: + logger.info("Sentry DSN not provided, error tracking disabled") + return + + try: + import sentry_sdk + + sentry_sdk.init( + dsn=dsn, + environment=environment, + release=release, + traces_sample_rate=0.1, + ) + self._initialized = True + self._dsn = dsn + logger.info(f"Sentry initialized for environment: {environment}") + except ImportError: + logger.warning("sentry_sdk not installed, error tracking disabled") + + def capture_exception(self, error: Exception) -> str | None: + """ + Capture an exception. + + Args: + error: Exception to capture + + Returns: + Event ID if captured, None otherwise + """ + if not self._initialized: + return None + + try: + import sentry_sdk + + return sentry_sdk.capture_exception(error) + except ImportError: + return None + + def capture_message(self, message: str, level: str = "info") -> str | None: + """ + Capture a message. + + Args: + message: Message to capture + level: Log level + + Returns: + Event ID if captured, None otherwise + """ + if not self._initialized: + return None + + try: + import sentry_sdk + + return sentry_sdk.capture_message(message, level=level) + except ImportError: + return None + + +# Global Sentry instance +sentry = SentryIntegration() + + +# ============================================================================= +# External Tool URLs +# ============================================================================= + + +@dataclass +class ExternalToolConfig: + """Configuration for external monitoring tools.""" + + flower_url: str | None = None + grafana_url: str | None = None + prometheus_url: str | None = None + + def to_dict(self) -> dict[str, str | None]: + return { + "flower": self.flower_url, + "grafana": self.grafana_url, + "prometheus": self.prometheus_url, + } + + +# Global external tool config +external_tools = ExternalToolConfig() + + +# ============================================================================= +# Health API Router +# ============================================================================= + +health_router = APIRouter(tags=["Health"]) + + +@health_router.get("/health") +async def health_check() -> dict[str, Any]: + """ + Aggregated health check endpoint. + + Returns combined health status from all registered checks. + """ + result = health_registry.run_all() + return result.to_dict() + + +@health_router.get("/health/live") +async def liveness_check() -> dict[str, str]: + """ + Kubernetes liveness probe endpoint. + + Returns 200 if the application is running. + """ + return {"status": "alive"} + + +@health_router.get("/health/ready") +async def readiness_check() -> dict[str, Any]: + """ + Kubernetes readiness probe endpoint. + + Returns 200 if the application is ready to serve traffic. + """ + result = health_registry.run_all() + return { + "status": "ready" if result.status != HealthStatus.UNHEALTHY else "not_ready", + "health": result.status.value, + } + + +@health_router.get("/metrics") +async def metrics_endpoint() -> Response: + """ + Prometheus metrics endpoint. + + Returns metrics in Prometheus text format for scraping. + """ + content = metrics_registry.generate_latest() + return Response( + content=content, + media_type="text/plain; charset=utf-8", + ) + + +@health_router.get("/health/tools") +async def external_tools_endpoint() -> dict[str, str | None]: + """ + Get URLs for external monitoring tools. + + Returns links to Flower, Grafana, etc. + """ + return external_tools.to_dict() + + +# ============================================================================= +# Initialization Functions +# ============================================================================= + + +def init_observability( + enable_metrics: bool = False, + sentry_dsn: str | None = None, + environment: str = "development", + flower_url: str | None = None, + grafana_url: str | None = None, +) -> None: + """ + Initialize observability stack. + + Args: + enable_metrics: Whether to enable Prometheus metrics + sentry_dsn: Sentry DSN for error tracking + environment: Environment name (development, staging, production) + flower_url: URL to Flower dashboard + grafana_url: URL to Grafana dashboards + """ + logger.info("Initializing observability stack...") + + # Enable metrics if requested + if enable_metrics: + metrics_registry.enable() + + # Initialize Sentry + if sentry_dsn: + sentry.init(dsn=sentry_dsn, environment=environment) + + # Configure external tools + external_tools.flower_url = flower_url + external_tools.grafana_url = grafana_url + + logger.info("Observability stack initialized") + + +def shutdown_observability() -> None: + """Shutdown observability stack.""" + logger.info("Shutting down observability stack...") + metrics_registry.disable() + + +def register_module_health_checks() -> None: + """ + Register health checks from all modules. + + This should be called after all modules are loaded. + """ + from app.modules.registry import MODULES + + for module in MODULES.values(): + if module.health_check: + health_registry.register_check( + f"module:{module.code}", + lambda m=module: HealthCheckResult( + name=f"module:{m.code}", + **m.get_health_status(), + ), + ) + logger.debug(f"Registered health check for module {module.code}") + + +__all__ = [ + # Health checks + "HealthStatus", + "HealthCheckResult", + "AggregatedHealth", + "HealthCheckRegistry", + "health_registry", + # Metrics + "MetricsRegistry", + "metrics_registry", + # Sentry + "SentryIntegration", + "sentry", + # External tools + "ExternalToolConfig", + "external_tools", + # Router + "health_router", + # Initialization + "init_observability", + "shutdown_observability", + "register_module_health_checks", +] diff --git a/app/modules/__init__.py b/app/modules/__init__.py index 85b116af..b7941263 100644 --- a/app/modules/__init__.py +++ b/app/modules/__init__.py @@ -4,6 +4,16 @@ Modular Platform Architecture. This package provides a module system for enabling/disabling feature bundles per platform. +Three-Tier Classification: +1. CORE MODULES (4) - Always enabled, cannot be disabled + - core, tenancy, cms, customers + +2. OPTIONAL MODULES (7) - Can be enabled/disabled per platform + - payments, billing, inventory, orders, marketplace, analytics, messaging + +3. INTERNAL MODULES (2) - Admin-only tools, not customer-facing + - dev-tools, monitoring + Module Hierarchy: Global (SaaS Provider) └── Platform (Business Product - OMS, Loyalty, etc.) @@ -11,7 +21,8 @@ Module Hierarchy: ├── Routes (API + Page routes) ├── Services (Business logic) ├── Menu Items (Sidebar entries) - └── Templates (UI components) + ├── Templates (UI components) + └── Migrations (Module-specific) Modules vs Features: - Features: Granular capabilities (e.g., analytics_dashboard, letzshop_sync) @@ -22,7 +33,8 @@ Modules vs Features: Usage: from app.modules import module_service from app.modules.base import ModuleDefinition - from app.modules.registry import MODULES + from app.modules.registry import MODULES, CORE_MODULES, OPTIONAL_MODULES + from app.modules.events import module_event_bus, ModuleEvent # Check if module is enabled for platform if module_service.is_module_enabled(platform_id, "billing"): @@ -33,15 +45,55 @@ Usage: # Get all enabled modules for platform modules = module_service.get_platform_modules(platform_id) + + # Subscribe to module events + @module_event_bus.subscribe(ModuleEvent.ENABLED) + def on_enabled(data): + print(f"Module {data.module_code} enabled") """ from app.modules.base import ModuleDefinition -from app.modules.registry import MODULES +from app.modules.registry import ( + MODULES, + CORE_MODULES, + OPTIONAL_MODULES, + INTERNAL_MODULES, + get_core_module_codes, + get_optional_module_codes, + get_internal_module_codes, + get_module_tier, + is_core_module, + is_internal_module, +) from app.modules.service import ModuleService, module_service +from app.modules.events import ( + ModuleEvent, + ModuleEventData, + ModuleEventBus, + module_event_bus, +) __all__ = [ + # Core types "ModuleDefinition", + # Module dictionaries "MODULES", + "CORE_MODULES", + "OPTIONAL_MODULES", + "INTERNAL_MODULES", + # Helper functions + "get_core_module_codes", + "get_optional_module_codes", + "get_internal_module_codes", + "get_module_tier", + "is_core_module", + "is_internal_module", + # Service "ModuleService", "module_service", + # Events + "ModuleEvent", + "ModuleEventData", + "ModuleEventBus", + "module_event_bus", ] diff --git a/app/modules/base.py b/app/modules/base.py index 93962a6a..233f6ed4 100644 --- a/app/modules/base.py +++ b/app/modules/base.py @@ -11,6 +11,12 @@ per platform. Each module contains: - Models: Database models (optional, for self-contained modules) - Schemas: Pydantic schemas (optional, for self-contained modules) - Templates: Jinja2 templates (optional, for self-contained modules) +- Migrations: Database migrations (optional, for self-contained modules) + +Module Classification: +- Core modules: Always enabled, cannot be disabled (core, tenancy, cms, customers) +- Optional modules: Can be enabled/disabled per platform +- Internal modules: Admin-only tools, not customer-facing (dev-tools, monitoring) Self-Contained Module Structure: app/modules// @@ -22,15 +28,19 @@ Self-Contained Module Structure: ├── services/ # Business logic ├── models/ # SQLAlchemy models ├── schemas/ # Pydantic schemas - └── templates/ # Jinja2 templates (namespaced) + ├── migrations/ # Alembic migrations for this module + │ └── versions/ # Migration scripts + ├── templates/ # Jinja2 templates (namespaced) + └── locales/ # Translation files """ from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Callable if TYPE_CHECKING: from fastapi import APIRouter + from pydantic import BaseModel from models.database.admin_menu_config import FrontendType @@ -43,26 +53,51 @@ class ModuleDefinition: A module groups related functionality that can be enabled/disabled per platform. Core modules cannot be disabled and are always available. - Self-contained modules include their own services, models, schemas, and templates. - The path attributes describe where these components are located. + Self-contained modules include their own services, models, schemas, templates, + and migrations. The path attributes describe where these components are located. Attributes: + # Identity code: Unique identifier (e.g., "billing", "marketplace") name: Display name (e.g., "Billing & Subscriptions") description: Description of what this module provides + version: Semantic version of the module (e.g., "1.0.0") + + # Dependencies requires: List of module codes this module depends on + + # Components features: List of feature codes this module provides menu_items: Dict mapping FrontendType to list of menu item IDs + permissions: List of permission codes this module defines + + # Classification is_core: Core modules cannot be disabled + is_internal: Internal modules are admin-only (not customer-facing) + + # Configuration + config_schema: Pydantic model class for module configuration + default_config: Default configuration values + + # Routes admin_router: FastAPI router for admin routes vendor_router: FastAPI router for vendor routes - services_path: Path to services subpackage (e.g., "app.modules.billing.services") - models_path: Path to models subpackage (e.g., "app.modules.billing.models") - schemas_path: Path to schemas subpackage (e.g., "app.modules.billing.schemas") - templates_path: Path to templates directory (relative to module) - exceptions_path: Path to exceptions module (e.g., "app.modules.billing.exceptions") - locales_path: Path to locales directory (relative to module, e.g., "locales") + + # Lifecycle hooks + on_enable: Called when module is enabled for a platform + on_disable: Called when module is disabled for a platform + on_startup: Called when application starts (for enabled modules) + health_check: Called to check module health status + + # Self-contained module paths (optional) is_self_contained: Whether module uses self-contained structure + services_path: Path to services subpackage + models_path: Path to models subpackage + schemas_path: Path to schemas subpackage + templates_path: Path to templates directory (relative to module) + exceptions_path: Path to exceptions module + locales_path: Path to locales directory (relative to module) + migrations_path: Path to migrations directory (relative to module) Example (traditional thin wrapper): billing_module = ModuleDefinition( @@ -76,53 +111,89 @@ class ModuleDefinition: }, ) - Example (self-contained module): + Example (self-contained module with configuration): + from pydantic import BaseModel, Field + + class CMSConfig(BaseModel): + max_pages: int = Field(default=100, ge=1) + enable_seo: bool = True + cms_module = ModuleDefinition( code="cms", name="Content Management", - description="Content pages, media library, and vendor themes.", + version="1.0.0", features=["cms_basic", "cms_custom_pages"], - menu_items={ - FrontendType.ADMIN: ["content-pages"], - FrontendType.VENDOR: ["content-pages", "media"], - }, + config_schema=CMSConfig, + default_config={"max_pages": 100, "enable_seo": True}, is_self_contained=True, services_path="app.modules.cms.services", models_path="app.modules.cms.models", - schemas_path="app.modules.cms.schemas", - templates_path="templates", - exceptions_path="app.modules.cms.exceptions", - locales_path="locales", + migrations_path="migrations", + health_check=lambda: {"status": "healthy"}, ) """ + # ========================================================================= # Identity + # ========================================================================= code: str name: str description: str = "" + version: str = "1.0.0" + # ========================================================================= # Dependencies + # ========================================================================= requires: list[str] = field(default_factory=list) + # ========================================================================= # Components + # ========================================================================= features: list[str] = field(default_factory=list) menu_items: dict[FrontendType, list[str]] = field(default_factory=dict) + permissions: list[str] = field(default_factory=list) - # Status + # ========================================================================= + # Classification + # ========================================================================= is_core: bool = False + is_internal: bool = False + # ========================================================================= + # Configuration + # ========================================================================= + config_schema: "type[BaseModel] | None" = None + default_config: dict[str, Any] = field(default_factory=dict) + + # ========================================================================= # Routes (registered dynamically) + # ========================================================================= admin_router: "APIRouter | None" = None vendor_router: "APIRouter | None" = None - # Self-contained module paths (optional) + # ========================================================================= + # Lifecycle Hooks + # ========================================================================= + on_enable: Callable[[int], None] | None = None # Called with platform_id + on_disable: Callable[[int], None] | None = None # Called with platform_id + on_startup: Callable[[], None] | None = None # Called on app startup + health_check: Callable[[], dict[str, Any]] | None = None # Returns health status + + # ========================================================================= + # Self-Contained Module Paths (optional) + # ========================================================================= is_self_contained: bool = False services_path: str | None = None models_path: str | None = None schemas_path: str | None = None templates_path: str | None = None # Relative to module directory exceptions_path: str | None = None - locales_path: str | None = None # Relative to module directory, e.g., "locales" + locales_path: str | None = None # Relative to module directory + migrations_path: str | None = None # Relative to module directory, e.g., "migrations" + + # ========================================================================= + # Menu Item Methods + # ========================================================================= def get_menu_items(self, frontend_type: FrontendType) -> list[str]: """Get menu item IDs for a specific frontend type.""" @@ -135,13 +206,25 @@ class ModuleDefinition: all_items.update(items) return all_items + def has_menu_item(self, menu_item_id: str) -> bool: + """Check if this module provides a specific menu item.""" + return menu_item_id in self.get_all_menu_items() + + # ========================================================================= + # Feature Methods + # ========================================================================= + def has_feature(self, feature_code: str) -> bool: """Check if this module provides a specific feature.""" return feature_code in self.features - def has_menu_item(self, menu_item_id: str) -> bool: - """Check if this module provides a specific menu item.""" - return menu_item_id in self.get_all_menu_items() + def has_permission(self, permission_code: str) -> bool: + """Check if this module defines a specific permission.""" + return permission_code in self.permissions + + # ========================================================================= + # Dependency Methods + # ========================================================================= def check_dependencies(self, enabled_modules: set[str]) -> list[str]: """ @@ -155,6 +238,70 @@ class ModuleDefinition: """ return [req for req in self.requires if req not in enabled_modules] + # ========================================================================= + # Configuration Methods + # ========================================================================= + + def validate_config(self, config: dict[str, Any]) -> dict[str, Any]: + """ + Validate configuration against the schema. + + Args: + config: Configuration dict to validate + + Returns: + Validated configuration dict + + Raises: + ValidationError: If configuration is invalid + """ + if self.config_schema is None: + return config + + # Merge with defaults + merged = {**self.default_config, **config} + # Validate using Pydantic model + validated = self.config_schema(**merged) + return validated.model_dump() + + def get_default_config(self) -> dict[str, Any]: + """Get the default configuration for this module.""" + if self.config_schema is None: + return self.default_config.copy() + + # Use Pydantic model defaults + return self.config_schema().model_dump() + + # ========================================================================= + # Lifecycle Methods + # ========================================================================= + + def run_on_enable(self, platform_id: int) -> None: + """Run the on_enable hook if defined.""" + if self.on_enable: + self.on_enable(platform_id) + + def run_on_disable(self, platform_id: int) -> None: + """Run the on_disable hook if defined.""" + if self.on_disable: + self.on_disable(platform_id) + + def run_on_startup(self) -> None: + """Run the on_startup hook if defined.""" + if self.on_startup: + self.on_startup() + + def get_health_status(self) -> dict[str, Any]: + """ + Get the health status of this module. + + Returns: + Dict with at least a 'status' key ('healthy', 'degraded', 'unhealthy') + """ + if self.health_check: + return self.health_check() + return {"status": "healthy", "message": "No health check defined"} + # ========================================================================= # Self-Contained Module Methods # ========================================================================= @@ -166,7 +313,9 @@ class ModuleDefinition: Returns: Path to app/modules// """ - return Path(__file__).parent / self.code + # Handle module codes with hyphens (e.g., dev-tools -> dev_tools) + dir_name = self.code.replace("-", "_") + return Path(__file__).parent / dir_name def get_templates_dir(self) -> Path | None: """ @@ -190,6 +339,17 @@ class ModuleDefinition: return None return self.get_module_dir() / self.locales_path + def get_migrations_dir(self) -> Path | None: + """ + Get the filesystem path to this module's migrations directory. + + Returns: + Path to migrations directory, or None if not configured + """ + if not self.migrations_path: + return None + return self.get_module_dir() / self.migrations_path + def get_import_path(self, component: str) -> str | None: """ Get the Python import path for a module component. @@ -237,6 +397,8 @@ class ModuleDefinition: expected_dirs.append(self.templates_path) if self.locales_path: expected_dirs.append(self.locales_path) + if self.migrations_path: + expected_dirs.append(self.migrations_path) for dir_name in expected_dirs: dir_path = module_dir / dir_name @@ -245,6 +407,28 @@ class ModuleDefinition: return issues + # ========================================================================= + # Classification Methods + # ========================================================================= + + def get_tier(self) -> str: + """ + Get the tier classification of this module. + + Returns: + 'core', 'internal', or 'optional' + """ + if self.is_core: + return "core" + elif self.is_internal: + return "internal" + else: + return "optional" + + # ========================================================================= + # Magic Methods + # ========================================================================= + def __hash__(self) -> int: return hash(self.code) @@ -254,5 +438,6 @@ class ModuleDefinition: return False def __repr__(self) -> str: + tier = self.get_tier() sc = ", self_contained" if self.is_self_contained else "" - return f"" + return f"" diff --git a/app/modules/billing/definition.py b/app/modules/billing/definition.py index 28450757..ca7ceff4 100644 --- a/app/modules/billing/definition.py +++ b/app/modules/billing/definition.py @@ -29,13 +29,14 @@ billing_module = ModuleDefinition( code="billing", name="Billing & Subscriptions", description=( - "Subscription tier management, vendor billing, payment processing, " - "and invoice history. Integrates with Stripe for payment collection." + "Platform subscription management, vendor billing, and invoice history. " + "Uses the payments module for actual payment processing." ), + version="1.0.0", + requires=["payments"], # Depends on payments module for payment processing features=[ "subscription_management", # Manage subscription tiers "billing_history", # View invoices and payment history - "stripe_integration", # Stripe payment processing "invoice_generation", # Generate and download invoices "subscription_analytics", # Subscription stats and metrics "trial_management", # Manage vendor trial periods diff --git a/app/modules/cms/definition.py b/app/modules/cms/definition.py index 5c70ef69..9bde089a 100644 --- a/app/modules/cms/definition.py +++ b/app/modules/cms/definition.py @@ -53,7 +53,7 @@ cms_module = ModuleDefinition( "media", # Media library ], }, - is_core=False, + is_core=True, # CMS is a core module - content management is fundamental # Self-contained module configuration is_self_contained=True, services_path="app.modules.cms.services", diff --git a/app/modules/customers/definition.py b/app/modules/customers/definition.py index e20517f6..ab80e176 100644 --- a/app/modules/customers/definition.py +++ b/app/modules/customers/definition.py @@ -43,7 +43,7 @@ customers_module = ModuleDefinition( "customers", # Vendor customer list ], }, - is_core=False, + is_core=True, # Customers is a core module - customer data is fundamental ) diff --git a/app/modules/dev_tools/definition.py b/app/modules/dev_tools/definition.py index 947c98e1..eb8652f2 100644 --- a/app/modules/dev_tools/definition.py +++ b/app/modules/dev_tools/definition.py @@ -17,6 +17,7 @@ dev_tools_module = ModuleDefinition( code="dev-tools", name="Developer Tools", description="Component library and icon browser for development.", + version="1.0.0", features=[ "component_library", # UI component browser "icon_browser", # Icon library browser @@ -29,6 +30,7 @@ dev_tools_module = ModuleDefinition( FrontendType.VENDOR: [], # No vendor menu items }, is_core=False, + is_internal=True, # Internal module - admin-only, not customer-facing ) diff --git a/app/modules/events.py b/app/modules/events.py new file mode 100644 index 00000000..255f308e --- /dev/null +++ b/app/modules/events.py @@ -0,0 +1,325 @@ +# app/modules/events.py +""" +Module event bus for lifecycle events. + +Provides a way for different parts of the application to subscribe to +module lifecycle events (enable, disable, startup, shutdown, config changes). + +Usage: + from app.modules.events import module_event_bus, ModuleEvent + + # Subscribe to events + @module_event_bus.subscribe(ModuleEvent.ENABLED) + def on_module_enabled(event_data: ModuleEventData): + print(f"Module {event_data.module_code} enabled for platform {event_data.platform_id}") + + # Or subscribe manually + module_event_bus.subscribe(ModuleEvent.DISABLED, my_handler) + + # Emit events (typically done by ModuleService) + module_event_bus.emit(ModuleEvent.ENABLED, ModuleEventData( + module_code="billing", + platform_id=1, + user_id=42, + )) +""" + +import logging +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import Enum +from typing import Any, Callable + +logger = logging.getLogger(__name__) + + +class ModuleEvent(str, Enum): + """Module lifecycle events.""" + + # Module enablement + ENABLED = "enabled" # Module enabled for a platform + DISABLED = "disabled" # Module disabled for a platform + + # Application lifecycle + STARTUP = "startup" # Application starting, module initializing + SHUTDOWN = "shutdown" # Application shutting down + + # Configuration + CONFIG_CHANGED = "config_changed" # Module configuration updated + + # Health + HEALTH_CHECK = "health_check" # Health check performed + + +@dataclass +class ModuleEventData: + """ + Data passed with module events. + + Attributes: + module_code: The module code (e.g., "billing", "cms") + platform_id: Platform ID if event is platform-specific + user_id: User ID if action was user-initiated + config: Configuration data (for CONFIG_CHANGED) + metadata: Additional event-specific data + timestamp: When the event occurred + """ + + module_code: str + platform_id: int | None = None + user_id: int | None = None + config: dict[str, Any] | None = None + metadata: dict[str, Any] = field(default_factory=dict) + timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + +# Type alias for event handlers +EventHandler = Callable[[ModuleEventData], None] + + +class ModuleEventBus: + """ + Event bus for module lifecycle events. + + Allows components to subscribe to and emit module events. + This enables loose coupling between the module system and other + parts of the application that need to react to module changes. + + Example: + # In a service that needs to react to module changes + @module_event_bus.subscribe(ModuleEvent.ENABLED) + def on_billing_enabled(data: ModuleEventData): + if data.module_code == "billing": + setup_stripe_webhook(data.platform_id) + + # Or subscribe to multiple events + @module_event_bus.subscribe(ModuleEvent.ENABLED, ModuleEvent.DISABLED) + def on_module_change(data: ModuleEventData): + clear_menu_cache(data.platform_id) + """ + + def __init__(self) -> None: + self._handlers: dict[ModuleEvent, list[EventHandler]] = { + event: [] for event in ModuleEvent + } + self._global_handlers: list[EventHandler] = [] + + def subscribe( + self, + *events: ModuleEvent, + ) -> Callable[[EventHandler], EventHandler]: + """ + Decorator to subscribe a handler to one or more events. + + Args: + events: Events to subscribe to. If empty, subscribes to all events. + + Returns: + Decorator function + + Example: + @module_event_bus.subscribe(ModuleEvent.ENABLED) + def on_enabled(data: ModuleEventData): + print(f"Module {data.module_code} enabled") + """ + + def decorator(handler: EventHandler) -> EventHandler: + if not events: + # Subscribe to all events + self._global_handlers.append(handler) + else: + for event in events: + self._handlers[event].append(handler) + return handler + + return decorator + + def subscribe_handler( + self, + event: ModuleEvent, + handler: EventHandler, + ) -> None: + """ + Subscribe a handler to a specific event. + + Args: + event: Event to subscribe to + handler: Handler function to call + """ + self._handlers[event].append(handler) + + def unsubscribe( + self, + event: ModuleEvent, + handler: EventHandler, + ) -> bool: + """ + Unsubscribe a handler from an event. + + Args: + event: Event to unsubscribe from + handler: Handler function to remove + + Returns: + True if handler was found and removed + """ + try: + self._handlers[event].remove(handler) + return True + except ValueError: + return False + + def emit( + self, + event: ModuleEvent, + data: ModuleEventData, + ) -> None: + """ + Emit an event to all subscribed handlers. + + Args: + event: Event type to emit + data: Event data to pass to handlers + + Note: + Handlers are called synchronously in registration order. + Exceptions in handlers are logged but don't prevent other + handlers from being called. + """ + logger.debug( + f"Emitting {event.value} event for module {data.module_code}" + + (f" on platform {data.platform_id}" if data.platform_id else "") + ) + + # Call event-specific handlers + for handler in self._handlers[event]: + try: + handler(data) + except Exception as e: + logger.exception( + f"Error in {event.value} handler {handler.__name__}: {e}" + ) + + # Call global handlers + for handler in self._global_handlers: + try: + handler(data) + except Exception as e: + logger.exception( + f"Error in global handler {handler.__name__}: {e}" + ) + + def emit_enabled( + self, + module_code: str, + platform_id: int, + user_id: int | None = None, + ) -> None: + """ + Convenience method to emit an ENABLED event. + + Args: + module_code: Module that was enabled + platform_id: Platform it was enabled for + user_id: User who enabled it + """ + self.emit( + ModuleEvent.ENABLED, + ModuleEventData( + module_code=module_code, + platform_id=platform_id, + user_id=user_id, + ), + ) + + def emit_disabled( + self, + module_code: str, + platform_id: int, + user_id: int | None = None, + ) -> None: + """ + Convenience method to emit a DISABLED event. + + Args: + module_code: Module that was disabled + platform_id: Platform it was disabled for + user_id: User who disabled it + """ + self.emit( + ModuleEvent.DISABLED, + ModuleEventData( + module_code=module_code, + platform_id=platform_id, + user_id=user_id, + ), + ) + + def emit_startup(self, module_code: str) -> None: + """ + Convenience method to emit a STARTUP event. + + Args: + module_code: Module that is starting up + """ + self.emit( + ModuleEvent.STARTUP, + ModuleEventData(module_code=module_code), + ) + + def emit_shutdown(self, module_code: str) -> None: + """ + Convenience method to emit a SHUTDOWN event. + + Args: + module_code: Module that is shutting down + """ + self.emit( + ModuleEvent.SHUTDOWN, + ModuleEventData(module_code=module_code), + ) + + def emit_config_changed( + self, + module_code: str, + platform_id: int, + config: dict[str, Any], + user_id: int | None = None, + ) -> None: + """ + Convenience method to emit a CONFIG_CHANGED event. + + Args: + module_code: Module whose config changed + platform_id: Platform the config belongs to + config: New configuration values + user_id: User who changed the config + """ + self.emit( + ModuleEvent.CONFIG_CHANGED, + ModuleEventData( + module_code=module_code, + platform_id=platform_id, + config=config, + user_id=user_id, + ), + ) + + def clear_handlers(self) -> None: + """Clear all handlers. Useful for testing.""" + for event in ModuleEvent: + self._handlers[event] = [] + self._global_handlers = [] + + +# Global event bus instance +module_event_bus = ModuleEventBus() + + +__all__ = [ + "ModuleEvent", + "ModuleEventData", + "ModuleEventBus", + "EventHandler", + "module_event_bus", +] diff --git a/app/modules/migrations.py b/app/modules/migrations.py new file mode 100644 index 00000000..2cc83d78 --- /dev/null +++ b/app/modules/migrations.py @@ -0,0 +1,253 @@ +# app/modules/migrations.py +""" +Module migration discovery utility. + +Provides utilities for discovering and managing module-specific migrations. +Each self-contained module can have its own migrations directory that will +be included in Alembic's version locations. + +Module Migration Structure: + app/modules// + └── migrations/ + └── versions/ + ├── _001_initial.py + ├── _002_add_field.py + └── ... + +Migration Naming Convention: + {module_code}_{sequence}_{description}.py + Example: cms_001_create_content_pages.py + +This ensures no collision between modules and makes it clear which +module owns each migration. + +Usage: + # Get all migration paths for Alembic + from app.modules.migrations import get_all_migration_paths + + paths = get_all_migration_paths() + # Returns: [Path("alembic/versions"), Path("app/modules/cms/migrations/versions"), ...] + + # In alembic/env.py + context.configure( + version_locations=[str(p) for p in get_all_migration_paths()], + ... + ) +""" + +import logging +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from app.modules.base import ModuleDefinition + +logger = logging.getLogger(__name__) + + +def get_core_migrations_path() -> Path: + """ + Get the path to the core (non-module) migrations directory. + + Returns: + Path to alembic/versions/ + """ + # Navigate from app/modules/migrations.py to project root + project_root = Path(__file__).parent.parent.parent + return project_root / "alembic" / "versions" + + +def get_module_migrations_path(module: "ModuleDefinition") -> Path | None: + """ + Get the migrations path for a specific module. + + Args: + module: Module definition to get migrations path for + + Returns: + Path to module's migrations/versions/ directory, or None if not configured + """ + migrations_dir = module.get_migrations_dir() + if migrations_dir is None: + return None + + versions_dir = migrations_dir / "versions" + return versions_dir + + +def discover_module_migrations() -> list[Path]: + """ + Discover all module migration directories. + + Scans all registered modules for those with migrations_path configured + and returns paths to their versions directories. + + Returns: + List of paths to module migration version directories + """ + # Import here to avoid circular imports + from app.modules.registry import MODULES + + paths: list[Path] = [] + + for module in MODULES.values(): + if not module.migrations_path: + continue + + versions_path = get_module_migrations_path(module) + if versions_path is None: + continue + + if versions_path.exists(): + paths.append(versions_path) + logger.debug(f"Found migrations for module {module.code}: {versions_path}") + else: + logger.debug( + f"Module {module.code} has migrations_path but no versions directory" + ) + + return sorted(paths) # Sort for deterministic ordering + + +def get_all_migration_paths() -> list[Path]: + """ + Get all migration paths including core and module migrations. + + Returns: + List of paths starting with core migrations, followed by module migrations + in alphabetical order by module code. + + Example: + [ + Path("alembic/versions"), + Path("app/modules/billing/migrations/versions"), + Path("app/modules/cms/migrations/versions"), + ] + """ + paths = [get_core_migrations_path()] + paths.extend(discover_module_migrations()) + return paths + + +def get_migration_order() -> list[str]: + """ + Get the order in which module migrations should be applied. + + Returns migrations in dependency order - modules with no dependencies first, + then modules that depend on them, etc. + + Returns: + List of module codes in migration order + """ + # Import here to avoid circular imports + from app.modules.registry import MODULES + + # Build dependency graph + modules_with_migrations = [ + m for m in MODULES.values() if m.migrations_path + ] + + if not modules_with_migrations: + return [] + + # Topological sort based on dependencies + ordered: list[str] = [] + visited: set[str] = set() + temp_visited: set[str] = set() + + def visit(code: str) -> None: + if code in visited: + return + if code in temp_visited: + raise ValueError(f"Circular dependency detected involving {code}") + + module = MODULES.get(code) + if module is None: + return + + temp_visited.add(code) + + # Visit dependencies first + for dep in module.requires: + if dep in {m.code for m in modules_with_migrations}: + visit(dep) + + temp_visited.remove(code) + visited.add(code) + + if module.migrations_path: + ordered.append(code) + + for module in modules_with_migrations: + visit(module.code) + + return ordered + + +def validate_migration_names() -> list[str]: + """ + Validate that all module migrations follow the naming convention. + + Returns: + List of validation errors (empty if all valid) + """ + errors: list[str] = [] + + for path in discover_module_migrations(): + # Extract module code from path (e.g., app/modules/cms/migrations/versions -> cms) + module_dir = path.parent.parent # migrations/versions -> migrations -> module + module_code = module_dir.name.replace("_", "-") # cms or dev_tools -> dev-tools + + for migration_file in path.glob("*.py"): + name = migration_file.stem + if name == "__pycache__": + continue + + # Check prefix matches module code + expected_prefix = module_code.replace("-", "_") + if not name.startswith(f"{expected_prefix}_"): + errors.append( + f"Migration {migration_file} should start with '{expected_prefix}_'" + ) + + return errors + + +def create_module_migrations_dir(module: "ModuleDefinition") -> Path: + """ + Create the migrations directory structure for a module. + + Args: + module: Module to create migrations for + + Returns: + Path to the created versions directory + """ + module_dir = module.get_module_dir() + migrations_dir = module_dir / "migrations" + versions_dir = migrations_dir / "versions" + + versions_dir.mkdir(parents=True, exist_ok=True) + + # Create __init__.py files + init_files = [ + migrations_dir / "__init__.py", + versions_dir / "__init__.py", + ] + for init_file in init_files: + if not init_file.exists(): + init_file.write_text('"""Module migrations."""\n') + + logger.info(f"Created migrations directory for module {module.code}") + return versions_dir + + +__all__ = [ + "get_core_migrations_path", + "get_module_migrations_path", + "discover_module_migrations", + "get_all_migration_paths", + "get_migration_order", + "validate_migration_names", + "create_module_migrations_dir", +] diff --git a/app/modules/monitoring/definition.py b/app/modules/monitoring/definition.py index cd33f005..eb3de961 100644 --- a/app/modules/monitoring/definition.py +++ b/app/modules/monitoring/definition.py @@ -21,7 +21,8 @@ def _get_admin_router(): monitoring_module = ModuleDefinition( code="monitoring", name="Platform Monitoring", - description="Logs, background tasks, imports, and system health.", + description="Logs, background tasks, imports, system health, Flower, and Grafana integration.", + version="1.0.0", features=[ "application_logs", # Log viewing "background_tasks", # Task monitoring @@ -29,6 +30,8 @@ monitoring_module = ModuleDefinition( "capacity_monitoring", # System capacity "testing_hub", # Test runner "code_quality", # Code quality tools + "flower_integration", # Celery Flower link + "grafana_integration", # Grafana dashboard link ], menu_items={ FrontendType.ADMIN: [ @@ -42,6 +45,7 @@ monitoring_module = ModuleDefinition( FrontendType.VENDOR: [], # No vendor menu items }, is_core=False, + is_internal=True, # Internal module - admin-only, not customer-facing ) diff --git a/app/modules/orders/definition.py b/app/modules/orders/definition.py index 9b66546f..fb36954d 100644 --- a/app/modules/orders/definition.py +++ b/app/modules/orders/definition.py @@ -29,9 +29,11 @@ orders_module = ModuleDefinition( code="orders", name="Order Management", description=( - "Order processing, fulfillment tracking, order item exceptions, " - "and bulk order operations." + "Order processing, fulfillment tracking, customer checkout, " + "and bulk order operations. Uses the payments module for checkout." ), + version="1.0.0", + requires=["payments"], # Depends on payments module for checkout features=[ "order_management", # Basic order CRUD "order_bulk_actions", # Bulk status updates @@ -40,6 +42,7 @@ orders_module = ModuleDefinition( "fulfillment_tracking", # Shipping and tracking "shipping_management", # Carrier integration "order_exceptions", # Order item exception handling + "customer_checkout", # Customer checkout flow ], menu_items={ FrontendType.ADMIN: [ diff --git a/app/modules/payments/__init__.py b/app/modules/payments/__init__.py new file mode 100644 index 00000000..a5d2fc7a --- /dev/null +++ b/app/modules/payments/__init__.py @@ -0,0 +1,32 @@ +# app/modules/payments/__init__.py +""" +Payments Module - Payment gateway integrations. + +This module provides low-level payment gateway abstractions: +- Gateway integrations (Stripe, PayPal, Bank Transfer, etc.) +- Payment processing and refunds +- Payment method storage and management +- Transaction records + +This module is used by: +- billing: Platform subscriptions and vendor invoices +- orders: Customer checkout and order payments + +Routes: +- Admin: /api/v1/admin/payments/* +- Vendor: /api/v1/vendor/payments/* + +Menu Items: +- Admin: payments (payment configuration) +- Vendor: payment-methods (stored payment methods) +""" + +from app.modules.payments.definition import payments_module + + +def get_payments_module(): + """Lazy getter to avoid circular imports.""" + return payments_module + + +__all__ = ["payments_module", "get_payments_module"] diff --git a/app/modules/payments/definition.py b/app/modules/payments/definition.py new file mode 100644 index 00000000..0ebf6398 --- /dev/null +++ b/app/modules/payments/definition.py @@ -0,0 +1,79 @@ +# app/modules/payments/definition.py +""" +Payments module definition. + +Defines the payments module including its features, menu items, +and route configurations. + +The payments module provides gateway abstractions that can be used by: +- billing module: For platform subscriptions and invoices +- orders module: For customer checkout payments + +This separation allows: +1. Using payments standalone (e.g., one-time payments without subscriptions) +2. Billing without orders (platform subscription only) +3. Orders without billing (customer payments only) +""" + +from app.modules.base import ModuleDefinition +from models.database.admin_menu_config import FrontendType + + +def _get_admin_router(): + """Lazy import of admin router to avoid circular imports.""" + from app.modules.payments.routes.admin import admin_router + + return admin_router + + +def _get_vendor_router(): + """Lazy import of vendor router to avoid circular imports.""" + from app.modules.payments.routes.vendor import vendor_router + + return vendor_router + + +# Payments module definition +payments_module = ModuleDefinition( + code="payments", + name="Payment Gateways", + description=( + "Payment gateway integrations for Stripe, PayPal, and bank transfers. " + "Provides payment processing, refunds, and payment method management." + ), + version="1.0.0", + features=[ + "payment_processing", # Process payments + "payment_refunds", # Issue refunds + "payment_methods", # Store payment methods + "stripe_gateway", # Stripe integration + "paypal_gateway", # PayPal integration + "bank_transfer", # Bank transfer support + "transaction_history", # Transaction records + ], + menu_items={ + FrontendType.ADMIN: [ + "payment-gateways", # Configure payment gateways + ], + FrontendType.VENDOR: [ + "payment-methods", # Manage stored payment methods + ], + }, + is_core=False, + is_internal=False, +) + + +def get_payments_module_with_routers() -> ModuleDefinition: + """ + Get payments module with routers attached. + + This function attaches the routers lazily to avoid circular imports + during module initialization. + """ + payments_module.admin_router = _get_admin_router() + payments_module.vendor_router = _get_vendor_router() + return payments_module + + +__all__ = ["payments_module", "get_payments_module_with_routers"] diff --git a/app/modules/payments/models/__init__.py b/app/modules/payments/models/__init__.py new file mode 100644 index 00000000..96452e71 --- /dev/null +++ b/app/modules/payments/models/__init__.py @@ -0,0 +1,11 @@ +# app/modules/payments/models/__init__.py +""" +Payments module database models. + +Note: These models will be created in a future migration. +For now, payment data may be stored in the billing module's tables. +""" + +# TODO: Add Payment, PaymentMethod, Transaction models + +__all__: list[str] = [] diff --git a/app/modules/payments/routes/__init__.py b/app/modules/payments/routes/__init__.py new file mode 100644 index 00000000..9eb624cf --- /dev/null +++ b/app/modules/payments/routes/__init__.py @@ -0,0 +1,7 @@ +# app/modules/payments/routes/__init__.py +"""Payments module routes.""" + +from app.modules.payments.routes.admin import admin_router +from app.modules.payments.routes.vendor import vendor_router + +__all__ = ["admin_router", "vendor_router"] diff --git a/app/modules/payments/routes/admin.py b/app/modules/payments/routes/admin.py new file mode 100644 index 00000000..ee0b89c9 --- /dev/null +++ b/app/modules/payments/routes/admin.py @@ -0,0 +1,44 @@ +# app/modules/payments/routes/admin.py +""" +Admin routes for payments module. + +Provides routes for: +- Payment gateway configuration +- Transaction monitoring +- Refund management +""" + +from fastapi import APIRouter + +admin_router = APIRouter(prefix="/payments", tags=["Payments (Admin)"]) + + +@admin_router.get("/gateways") +async def list_gateways(): + """List configured payment gateways.""" + # TODO: Implement gateway listing + return { + "gateways": [ + {"code": "stripe", "name": "Stripe", "enabled": True}, + {"code": "paypal", "name": "PayPal", "enabled": False}, + {"code": "bank_transfer", "name": "Bank Transfer", "enabled": True}, + ] + } + + +@admin_router.get("/transactions") +async def list_transactions(): + """List recent transactions across all gateways.""" + # TODO: Implement transaction listing + return {"transactions": [], "total": 0} + + +@admin_router.post("/refunds/{transaction_id}") +async def issue_refund(transaction_id: str, amount: float | None = None): + """Issue a refund for a transaction.""" + # TODO: Implement refund logic + return { + "status": "pending", + "transaction_id": transaction_id, + "refund_amount": amount, + } diff --git a/app/modules/payments/routes/vendor.py b/app/modules/payments/routes/vendor.py new file mode 100644 index 00000000..ecca21d5 --- /dev/null +++ b/app/modules/payments/routes/vendor.py @@ -0,0 +1,40 @@ +# app/modules/payments/routes/vendor.py +""" +Vendor routes for payments module. + +Provides routes for: +- Payment method management +- Transaction history +""" + +from fastapi import APIRouter + +vendor_router = APIRouter(prefix="/payments", tags=["Payments (Vendor)"]) + + +@vendor_router.get("/methods") +async def list_payment_methods(): + """List saved payment methods for the vendor.""" + # TODO: Implement payment method listing + return {"payment_methods": []} + + +@vendor_router.post("/methods") +async def add_payment_method(): + """Add a new payment method.""" + # TODO: Implement payment method creation + return {"status": "created", "id": "pm_xxx"} + + +@vendor_router.delete("/methods/{method_id}") +async def remove_payment_method(method_id: str): + """Remove a saved payment method.""" + # TODO: Implement payment method deletion + return {"status": "deleted", "id": method_id} + + +@vendor_router.get("/transactions") +async def list_vendor_transactions(): + """List transactions for the vendor.""" + # TODO: Implement transaction listing + return {"transactions": [], "total": 0} diff --git a/app/modules/payments/schemas/__init__.py b/app/modules/payments/schemas/__init__.py new file mode 100644 index 00000000..a8efbba8 --- /dev/null +++ b/app/modules/payments/schemas/__init__.py @@ -0,0 +1,93 @@ +# app/modules/payments/schemas/__init__.py +""" +Payments module Pydantic schemas. +""" + +from pydantic import BaseModel, Field +from datetime import datetime +from typing import Any + + +class PaymentRequest(BaseModel): + """Request to process a payment.""" + + amount: int = Field(..., gt=0, description="Amount in cents") + currency: str = Field(default="EUR", max_length=3) + payment_method_id: str | None = None + gateway: str = Field(default="stripe") + description: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + +class PaymentResponse(BaseModel): + """Response from a payment operation.""" + + success: bool + transaction_id: str | None = None + gateway: str | None = None + status: str + amount: int + currency: str + error_message: str | None = None + created_at: datetime | None = None + + +class RefundRequest(BaseModel): + """Request to issue a refund.""" + + transaction_id: str + amount: int | None = Field(None, gt=0, description="Amount in cents, None for full refund") + reason: str | None = None + + +class RefundResponse(BaseModel): + """Response from a refund operation.""" + + success: bool + refund_id: str | None = None + transaction_id: str + amount: int + status: str + error_message: str | None = None + + +class PaymentMethodCreate(BaseModel): + """Request to create a payment method.""" + + gateway: str = "stripe" + token: str # Gateway-specific token + is_default: bool = False + + +class PaymentMethodResponse(BaseModel): + """Response for a payment method.""" + + id: str + gateway: str + type: str # card, bank_account, etc. + last4: str | None = None + is_default: bool + created_at: datetime + + +class GatewayResponse(BaseModel): + """Response for gateway info.""" + + code: str + name: str + status: str + enabled: bool + supports_refunds: bool + supports_recurring: bool + supported_currencies: list[str] + + +__all__ = [ + "PaymentRequest", + "PaymentResponse", + "RefundRequest", + "RefundResponse", + "PaymentMethodCreate", + "PaymentMethodResponse", + "GatewayResponse", +] diff --git a/app/modules/payments/services/__init__.py b/app/modules/payments/services/__init__.py new file mode 100644 index 00000000..48b158fb --- /dev/null +++ b/app/modules/payments/services/__init__.py @@ -0,0 +1,13 @@ +# app/modules/payments/services/__init__.py +""" +Payments module services. + +Provides: +- PaymentService: Core payment processing +- GatewayService: Gateway abstraction layer +""" + +from app.modules.payments.services.payment_service import PaymentService +from app.modules.payments.services.gateway_service import GatewayService + +__all__ = ["PaymentService", "GatewayService"] diff --git a/app/modules/payments/services/gateway_service.py b/app/modules/payments/services/gateway_service.py new file mode 100644 index 00000000..fcae5e97 --- /dev/null +++ b/app/modules/payments/services/gateway_service.py @@ -0,0 +1,351 @@ +# app/modules/payments/services/gateway_service.py +""" +Gateway service for managing payment gateway configurations. + +This service handles: +- Gateway configuration and credentials +- Gateway health checks +- Gateway-specific operations + +Each gateway has its own implementation that conforms to the +gateway protocol. + +Usage: + from app.modules.payments.services import GatewayService + + gateway_service = GatewayService() + + # Get available gateways + gateways = gateway_service.get_available_gateways() + + # Check gateway status + status = await gateway_service.check_gateway_health("stripe") +""" + +import logging +from abc import ABC, abstractmethod +from dataclasses import dataclass +from enum import Enum +from typing import Any, Protocol + +logger = logging.getLogger(__name__) + + +class GatewayStatus(str, Enum): + """Gateway operational status.""" + + ACTIVE = "active" + INACTIVE = "inactive" + ERROR = "error" + MAINTENANCE = "maintenance" + + +@dataclass +class GatewayInfo: + """Information about a payment gateway.""" + + code: str + name: str + status: GatewayStatus + enabled: bool + supports_refunds: bool = True + supports_recurring: bool = False + supported_currencies: list[str] | None = None + config: dict[str, Any] | None = None + + +class GatewayProtocol(Protocol): + """Protocol that all gateway implementations must follow.""" + + @property + def code(self) -> str: + """Gateway code identifier.""" + ... + + @property + def name(self) -> str: + """Gateway display name.""" + ... + + async def process_payment( + self, + amount: int, + currency: str, + payment_method: str, + **kwargs: Any, + ) -> dict[str, Any]: + """Process a payment.""" + ... + + async def refund( + self, + transaction_id: str, + amount: int | None = None, + ) -> dict[str, Any]: + """Issue a refund.""" + ... + + async def health_check(self) -> bool: + """Check if gateway is operational.""" + ... + + +class BaseGateway(ABC): + """Base class for gateway implementations.""" + + @property + @abstractmethod + def code(self) -> str: + """Gateway code identifier.""" + pass + + @property + @abstractmethod + def name(self) -> str: + """Gateway display name.""" + pass + + @abstractmethod + async def process_payment( + self, + amount: int, + currency: str, + payment_method: str, + **kwargs: Any, + ) -> dict[str, Any]: + """Process a payment.""" + pass + + @abstractmethod + async def refund( + self, + transaction_id: str, + amount: int | None = None, + ) -> dict[str, Any]: + """Issue a refund.""" + pass + + async def health_check(self) -> bool: + """Check if gateway is operational.""" + return True + + +class StripeGateway(BaseGateway): + """Stripe payment gateway implementation.""" + + @property + def code(self) -> str: + return "stripe" + + @property + def name(self) -> str: + return "Stripe" + + async def process_payment( + self, + amount: int, + currency: str, + payment_method: str, + **kwargs: Any, + ) -> dict[str, Any]: + """Process a payment through Stripe.""" + # TODO: Implement Stripe payment processing + logger.info(f"Processing Stripe payment: {amount} {currency}") + return { + "success": True, + "transaction_id": f"pi_mock_{amount}", + "gateway": self.code, + } + + async def refund( + self, + transaction_id: str, + amount: int | None = None, + ) -> dict[str, Any]: + """Issue a refund through Stripe.""" + # TODO: Implement Stripe refund + logger.info(f"Processing Stripe refund for {transaction_id}") + return { + "success": True, + "refund_id": f"re_mock_{transaction_id}", + } + + +class PayPalGateway(BaseGateway): + """PayPal payment gateway implementation.""" + + @property + def code(self) -> str: + return "paypal" + + @property + def name(self) -> str: + return "PayPal" + + async def process_payment( + self, + amount: int, + currency: str, + payment_method: str, + **kwargs: Any, + ) -> dict[str, Any]: + """Process a payment through PayPal.""" + # TODO: Implement PayPal payment processing + logger.info(f"Processing PayPal payment: {amount} {currency}") + return { + "success": True, + "transaction_id": f"paypal_mock_{amount}", + "gateway": self.code, + } + + async def refund( + self, + transaction_id: str, + amount: int | None = None, + ) -> dict[str, Any]: + """Issue a refund through PayPal.""" + # TODO: Implement PayPal refund + logger.info(f"Processing PayPal refund for {transaction_id}") + return { + "success": True, + "refund_id": f"paypal_refund_{transaction_id}", + } + + +class BankTransferGateway(BaseGateway): + """Bank transfer gateway implementation.""" + + @property + def code(self) -> str: + return "bank_transfer" + + @property + def name(self) -> str: + return "Bank Transfer" + + async def process_payment( + self, + amount: int, + currency: str, + payment_method: str, + **kwargs: Any, + ) -> dict[str, Any]: + """Record a bank transfer payment (manual verification).""" + logger.info(f"Recording bank transfer: {amount} {currency}") + return { + "success": True, + "transaction_id": f"bt_mock_{amount}", + "gateway": self.code, + "status": "pending_verification", + } + + async def refund( + self, + transaction_id: str, + amount: int | None = None, + ) -> dict[str, Any]: + """Record a bank transfer refund (manual process).""" + logger.info(f"Recording bank transfer refund for {transaction_id}") + return { + "success": True, + "refund_id": f"bt_refund_{transaction_id}", + "status": "pending_manual", + } + + +class GatewayService: + """ + Service for managing payment gateway configurations. + + Provides a registry of available gateways and methods for + gateway operations. + """ + + def __init__(self) -> None: + self._gateways: dict[str, BaseGateway] = { + "stripe": StripeGateway(), + "paypal": PayPalGateway(), + "bank_transfer": BankTransferGateway(), + } + self._enabled_gateways: set[str] = {"stripe", "bank_transfer"} + + def get_gateway(self, code: str) -> BaseGateway | None: + """Get a gateway by code.""" + return self._gateways.get(code) + + def get_available_gateways(self) -> list[GatewayInfo]: + """Get list of all available gateways with their status.""" + result = [] + for code, gateway in self._gateways.items(): + result.append( + GatewayInfo( + code=code, + name=gateway.name, + status=GatewayStatus.ACTIVE if code in self._enabled_gateways else GatewayStatus.INACTIVE, + enabled=code in self._enabled_gateways, + supports_refunds=True, + supports_recurring=code == "stripe", + supported_currencies=["EUR", "USD", "GBP"] if code != "bank_transfer" else ["EUR"], + ) + ) + return result + + def enable_gateway(self, code: str) -> bool: + """Enable a gateway.""" + if code in self._gateways: + self._enabled_gateways.add(code) + logger.info(f"Enabled gateway: {code}") + return True + return False + + def disable_gateway(self, code: str) -> bool: + """Disable a gateway.""" + if code in self._enabled_gateways: + self._enabled_gateways.remove(code) + logger.info(f"Disabled gateway: {code}") + return True + return False + + async def check_gateway_health(self, code: str) -> dict[str, Any]: + """Check the health of a specific gateway.""" + gateway = self._gateways.get(code) + if not gateway: + return {"status": "unknown", "message": f"Gateway {code} not found"} + + try: + is_healthy = await gateway.health_check() + return { + "status": "healthy" if is_healthy else "unhealthy", + "gateway": code, + } + except Exception as e: + logger.exception(f"Gateway health check failed: {code}") + return { + "status": "error", + "gateway": code, + "message": str(e), + } + + async def check_all_gateways(self) -> list[dict[str, Any]]: + """Check health of all enabled gateways.""" + results = [] + for code in self._enabled_gateways: + result = await self.check_gateway_health(code) + results.append(result) + return results + + +# Singleton instance +gateway_service = GatewayService() + +__all__ = [ + "GatewayService", + "GatewayStatus", + "GatewayInfo", + "GatewayProtocol", + "BaseGateway", + "StripeGateway", + "PayPalGateway", + "BankTransferGateway", + "gateway_service", +] diff --git a/app/modules/payments/services/payment_service.py b/app/modules/payments/services/payment_service.py new file mode 100644 index 00000000..009ee723 --- /dev/null +++ b/app/modules/payments/services/payment_service.py @@ -0,0 +1,232 @@ +# app/modules/payments/services/payment_service.py +""" +Payment service for processing payments through configured gateways. + +This service provides a unified interface for payment operations +regardless of the underlying gateway (Stripe, PayPal, etc.). + +Usage: + from app.modules.payments.services import PaymentService + + payment_service = PaymentService() + + # Process a payment + result = await payment_service.process_payment( + amount=1000, # Amount in cents + currency="EUR", + payment_method_id="pm_xxx", + description="Order #123", + ) + + # Issue a refund + refund = await payment_service.refund( + transaction_id="txn_xxx", + amount=500, # Partial refund + ) +""" + +import logging +from dataclasses import dataclass +from datetime import datetime, timezone +from decimal import Decimal +from enum import Enum +from typing import Any + +logger = logging.getLogger(__name__) + + +class PaymentStatus(str, Enum): + """Payment transaction status.""" + + PENDING = "pending" + PROCESSING = "processing" + SUCCEEDED = "succeeded" + FAILED = "failed" + CANCELLED = "cancelled" + REFUNDED = "refunded" + PARTIALLY_REFUNDED = "partially_refunded" + + +class PaymentGateway(str, Enum): + """Supported payment gateways.""" + + STRIPE = "stripe" + PAYPAL = "paypal" + BANK_TRANSFER = "bank_transfer" + + +@dataclass +class PaymentResult: + """Result of a payment operation.""" + + success: bool + transaction_id: str | None = None + gateway: PaymentGateway | None = None + status: PaymentStatus = PaymentStatus.PENDING + amount: int = 0 # Amount in cents + currency: str = "EUR" + error_message: str | None = None + gateway_response: dict[str, Any] | None = None + created_at: datetime | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "success": self.success, + "transaction_id": self.transaction_id, + "gateway": self.gateway.value if self.gateway else None, + "status": self.status.value, + "amount": self.amount, + "currency": self.currency, + "error_message": self.error_message, + "created_at": self.created_at.isoformat() if self.created_at else None, + } + + +@dataclass +class RefundResult: + """Result of a refund operation.""" + + success: bool + refund_id: str | None = None + transaction_id: str | None = None + amount: int = 0 + status: PaymentStatus = PaymentStatus.PENDING + error_message: str | None = None + + +class PaymentService: + """ + Service for processing payments through configured gateways. + + This service provides a unified interface for: + - Processing payments + - Issuing refunds + - Managing payment methods + - Retrieving transaction history + """ + + def __init__(self) -> None: + self._default_gateway = PaymentGateway.STRIPE + + async def process_payment( + self, + amount: int, + currency: str = "EUR", + payment_method_id: str | None = None, + gateway: PaymentGateway | None = None, + description: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> PaymentResult: + """ + Process a payment through the specified gateway. + + Args: + amount: Amount in cents + currency: Currency code (EUR, USD, etc.) + payment_method_id: Stored payment method ID + gateway: Payment gateway to use (default: stripe) + description: Payment description + metadata: Additional metadata for the transaction + + Returns: + PaymentResult with transaction details + """ + gateway = gateway or self._default_gateway + + logger.info( + f"Processing payment: {amount} {currency} via {gateway.value}", + extra={"amount": amount, "currency": currency, "gateway": gateway.value}, + ) + + # TODO: Implement actual gateway processing + # For now, return a mock successful result + return PaymentResult( + success=True, + transaction_id=f"txn_{gateway.value}_mock", + gateway=gateway, + status=PaymentStatus.SUCCEEDED, + amount=amount, + currency=currency, + created_at=datetime.now(timezone.utc), + ) + + async def refund( + self, + transaction_id: str, + amount: int | None = None, + reason: str | None = None, + ) -> RefundResult: + """ + Issue a refund for a transaction. + + Args: + transaction_id: Original transaction ID + amount: Refund amount in cents (None for full refund) + reason: Reason for refund + + Returns: + RefundResult with refund details + """ + logger.info( + f"Issuing refund for transaction {transaction_id}", + extra={"transaction_id": transaction_id, "amount": amount, "reason": reason}, + ) + + # TODO: Implement actual refund processing + return RefundResult( + success=True, + refund_id=f"rf_{transaction_id}", + transaction_id=transaction_id, + amount=amount or 0, + status=PaymentStatus.REFUNDED, + ) + + async def get_transaction(self, transaction_id: str) -> dict[str, Any] | None: + """ + Get transaction details by ID. + + Args: + transaction_id: Transaction ID + + Returns: + Transaction details or None if not found + """ + # TODO: Implement transaction lookup + return None + + async def list_transactions( + self, + vendor_id: int | None = None, + platform_id: int | None = None, + status: PaymentStatus | None = None, + limit: int = 50, + offset: int = 0, + ) -> list[dict[str, Any]]: + """ + List transactions with optional filters. + + Args: + vendor_id: Filter by vendor + platform_id: Filter by platform + status: Filter by status + limit: Maximum results + offset: Pagination offset + + Returns: + List of transaction records + """ + # TODO: Implement transaction listing + return [] + + +# Singleton instance +payment_service = PaymentService() + +__all__ = [ + "PaymentService", + "PaymentStatus", + "PaymentGateway", + "PaymentResult", + "RefundResult", + "payment_service", +] diff --git a/app/modules/registry.py b/app/modules/registry.py index c53827fa..6e0b3f70 100644 --- a/app/modules/registry.py +++ b/app/modules/registry.py @@ -2,14 +2,29 @@ """ Module registry defining all available platform modules. -Each module bundles related features and menu items that can be -enabled/disabled per platform. Core modules cannot be disabled. +The module system uses a three-tier classification: -Module Granularity (Medium - ~12 modules): -Matches menu sections for intuitive mapping between modules and UI. +1. CORE MODULES (4) - Always enabled, cannot be disabled + - core: Dashboard, settings, profile + - tenancy: Platform, company, vendor, admin user management + - cms: Content pages, media library, themes + - customers: Customer database, profiles, segmentation + +2. OPTIONAL MODULES (7) - Can be enabled/disabled per platform + - payments: Payment gateway integrations (Stripe, PayPal, etc.) + - billing: Platform subscriptions, vendor invoices (requires: payments) + - inventory: Stock management, locations + - orders: Order management, customer checkout (requires: payments) + - marketplace: Letzshop integration (requires: inventory) + - analytics: Reports, dashboards + - messaging: Messages, notifications + +3. INTERNAL MODULES (2) - Admin-only tools, not customer-facing + - dev-tools: Component library, icons + - monitoring: Logs, background tasks, Flower link, Grafana dashboards Module Structure: -- Inline modules: Defined directly in this file (core, platform-admin, etc.) +- Inline modules: Defined directly in this file (core, tenancy) - Extracted modules: Imported from app/modules/{module}/ (billing, etc.) As modules are extracted to their own directories, they are imported @@ -21,6 +36,7 @@ from models.database.admin_menu_config import FrontendType # Import extracted modules from app.modules.billing.definition import billing_module +from app.modules.payments.definition import payments_module from app.modules.inventory.definition import inventory_module from app.modules.marketplace.definition import marketplace_module from app.modules.orders.definition import orders_module @@ -33,13 +49,10 @@ from app.modules.monitoring.definition import monitoring_module # ============================================================================= -# Module Definitions +# Core Modules (Always Enabled, Cannot Be Disabled) # ============================================================================= -MODULES: dict[str, ModuleDefinition] = { - # ========================================================================= - # Core Modules (Always Enabled) - # ========================================================================= +CORE_MODULES: dict[str, ModuleDefinition] = { "core": ModuleDefinition( code="core", name="Core Platform", @@ -65,48 +78,67 @@ MODULES: dict[str, ModuleDefinition] = { ], }, ), - "platform-admin": ModuleDefinition( - code="platform-admin", - name="Platform Administration", - description="Company, vendor, and admin user management. Required for multi-tenant operation.", + "tenancy": ModuleDefinition( + code="tenancy", + name="Tenancy Management", + description="Platform, company, vendor, and admin user management. Required for multi-tenant operation.", is_core=True, features=[ + "platform_management", "company_management", "vendor_management", "admin_user_management", - "platform_management", ], menu_items={ FrontendType.ADMIN: [ - "admin-users", + "platforms", "companies", "vendors", - "platforms", + "admin-users", ], FrontendType.VENDOR: [ "team", ], }, ), - # ========================================================================= - # Optional Modules - # ========================================================================= + # CMS module - imported from app/modules/cms/ + "cms": cms_module, + # Customers module - imported from app/modules/customers/ + "customers": customers_module, +} + + +# ============================================================================= +# Optional Modules (Can Be Enabled/Disabled Per Platform) +# ============================================================================= + +OPTIONAL_MODULES: dict[str, ModuleDefinition] = { + # Payments module - imported from app/modules/payments/ + # Gateway integrations (Stripe, PayPal, etc.) + "payments": payments_module, # Billing module - imported from app/modules/billing/ + # Platform subscriptions, vendor invoices (requires: payments) "billing": billing_module, # Inventory module - imported from app/modules/inventory/ "inventory": inventory_module, # Orders module - imported from app/modules/orders/ + # Order management, customer checkout (requires: payments) "orders": orders_module, # Marketplace module - imported from app/modules/marketplace/ + # Letzshop integration (requires: inventory) "marketplace": marketplace_module, - # Customers module - imported from app/modules/customers/ - "customers": customers_module, - # CMS module - imported from app/modules/cms/ - "cms": cms_module, # Analytics module - imported from app/modules/analytics/ "analytics": analytics_module, # Messaging module - imported from app/modules/messaging/ "messaging": messaging_module, +} + + +# ============================================================================= +# Internal Modules (Admin-Only, Not Customer-Facing) +# ============================================================================= + +INTERNAL_MODULES: dict[str, ModuleDefinition] = { # Dev-Tools module - imported from app/modules/dev_tools/ "dev-tools": dev_tools_module, # Monitoring module - imported from app/modules/monitoring/ @@ -114,6 +146,17 @@ MODULES: dict[str, ModuleDefinition] = { } +# ============================================================================= +# Combined Module Registry +# ============================================================================= + +MODULES: dict[str, ModuleDefinition] = { + **CORE_MODULES, + **OPTIONAL_MODULES, + **INTERNAL_MODULES, +} + + # ============================================================================= # Helper Functions # ============================================================================= @@ -126,17 +169,32 @@ def get_module(code: str) -> ModuleDefinition | None: def get_core_modules() -> list[ModuleDefinition]: """Get all core modules (cannot be disabled).""" - return [m for m in MODULES.values() if m.is_core] + return list(CORE_MODULES.values()) def get_core_module_codes() -> set[str]: """Get codes of all core modules.""" - return {m.code for m in MODULES.values() if m.is_core} + return set(CORE_MODULES.keys()) def get_optional_modules() -> list[ModuleDefinition]: """Get all optional modules (can be enabled/disabled).""" - return [m for m in MODULES.values() if not m.is_core] + return list(OPTIONAL_MODULES.values()) + + +def get_optional_module_codes() -> set[str]: + """Get codes of all optional modules.""" + return set(OPTIONAL_MODULES.keys()) + + +def get_internal_modules() -> list[ModuleDefinition]: + """Get all internal modules (admin-only tools).""" + return list(INTERNAL_MODULES.values()) + + +def get_internal_module_codes() -> set[str]: + """Get codes of all internal modules.""" + return set(INTERNAL_MODULES.keys()) def get_all_module_codes() -> set[str]: @@ -144,6 +202,16 @@ def get_all_module_codes() -> set[str]: return set(MODULES.keys()) +def is_core_module(code: str) -> bool: + """Check if a module is a core module.""" + return code in CORE_MODULES + + +def is_internal_module(code: str) -> bool: + """Check if a module is an internal module.""" + return code in INTERNAL_MODULES + + def get_menu_item_module(menu_item_id: str, frontend_type: FrontendType) -> str | None: """ Find which module provides a specific menu item. @@ -202,6 +270,39 @@ def validate_module_dependencies() -> list[str]: return errors +def get_modules_by_tier() -> dict[str, list[ModuleDefinition]]: + """ + Get modules organized by tier. + + Returns: + Dict with keys 'core', 'optional', 'internal' mapping to module lists + """ + return { + "core": list(CORE_MODULES.values()), + "optional": list(OPTIONAL_MODULES.values()), + "internal": list(INTERNAL_MODULES.values()), + } + + +def get_module_tier(code: str) -> str | None: + """ + Get the tier classification of a module. + + Args: + code: Module code + + Returns: + 'core', 'optional', 'internal', or None if not found + """ + if code in CORE_MODULES: + return "core" + elif code in OPTIONAL_MODULES: + return "optional" + elif code in INTERNAL_MODULES: + return "internal" + return None + + # Validate dependencies on import (development check) _validation_errors = validate_module_dependencies() if _validation_errors: @@ -212,13 +313,28 @@ if _validation_errors: __all__ = [ + # Module dictionaries "MODULES", + "CORE_MODULES", + "OPTIONAL_MODULES", + "INTERNAL_MODULES", + # Module retrieval "get_module", "get_core_modules", "get_core_module_codes", "get_optional_modules", + "get_optional_module_codes", + "get_internal_modules", + "get_internal_module_codes", "get_all_module_codes", + # Module classification + "is_core_module", + "is_internal_module", + "get_modules_by_tier", + "get_module_tier", + # Menu and feature lookup "get_menu_item_module", "get_feature_module", + # Validation "validate_module_dependencies", ] diff --git a/docs/proposals/SESSION_NOTE_2026-01-27_module-reclassification.md b/docs/proposals/SESSION_NOTE_2026-01-27_module-reclassification.md new file mode 100644 index 00000000..a80284f1 --- /dev/null +++ b/docs/proposals/SESSION_NOTE_2026-01-27_module-reclassification.md @@ -0,0 +1,280 @@ +# Session Note: Module Reclassification & Framework Layer + +**Date:** 2026-01-27 +**Previous Session:** SESSION_NOTE_2026-01-26_self-contained-modules.md +**Tag:** v0.9.0-pre-framework-refactor + +## Summary + +Implemented a three-tier module classification system and added core framework infrastructure: + +1. **Three-Tier Classification** + - Core modules (4): Always enabled, cannot be disabled + - Optional modules (7): Can be enabled/disabled per platform + - Internal modules (2): Admin-only tools, not customer-facing + +2. **Module Renaming** + - Renamed `platform-admin` → `tenancy` + +3. **Core Module Promotion** + - Promoted CMS and Customers to core (is_core=True) + +4. **New Payments Module** + - Extracted payment gateway logic into standalone module + - Billing and Orders now depend on Payments + +5. **Framework Layer Infrastructure** + - Module events system (events.py) + - Module-specific migrations support (migrations.py) + - Observability framework (observability.py) + +--- + +## Final Module Classification + +### Core Modules (4) +| Module | Description | +|--------|-------------| +| `core` | Dashboard, settings, profile | +| `tenancy` | Platform, company, vendor, admin user management | +| `cms` | Content pages, media library, themes | +| `customers` | Customer database, profiles, segmentation | + +### Optional Modules (7) +| Module | Dependencies | Description | +|--------|--------------|-------------| +| `payments` | - | Payment gateway integrations (Stripe, PayPal, etc.) | +| `billing` | payments | Platform subscriptions, vendor invoices | +| `inventory` | - | Stock management, locations | +| `orders` | payments | Order management, customer checkout | +| `marketplace` | inventory | Letzshop integration | +| `analytics` | - | Reports, dashboards | +| `messaging` | - | Messages, notifications | + +### Internal Modules (2) +| Module | Description | +|--------|-------------| +| `dev-tools` | Component library, icons | +| `monitoring` | Logs, background tasks, Flower, Grafana | + +--- + +## Key Files Modified/Created + +### Modified +- `app/modules/registry.py` - Three-tier classification with CORE_MODULES, OPTIONAL_MODULES, INTERNAL_MODULES +- `app/modules/base.py` - Enhanced ModuleDefinition with new fields: + - `version` - Semantic versioning + - `is_internal` - Internal module flag + - `permissions` - Module-specific permissions + - `config_schema` - Pydantic config validation + - `default_config` - Default configuration values + - `migrations_path` - Module-specific migrations + - Lifecycle hooks: `on_enable`, `on_disable`, `on_startup`, `health_check` +- `app/modules/service.py` - No changes needed (uses registry functions) +- `app/modules/cms/definition.py` - Set is_core=True +- `app/modules/customers/definition.py` - Set is_core=True +- `app/modules/billing/definition.py` - Added requires=["payments"] +- `app/modules/orders/definition.py` - Added requires=["payments"] +- `app/modules/dev_tools/definition.py` - Added is_internal=True +- `app/modules/monitoring/definition.py` - Added is_internal=True +- `alembic/env.py` - Added module migration discovery + +### Created +- `app/modules/events.py` - Module event bus + - ModuleEvent enum (ENABLED, DISABLED, STARTUP, SHUTDOWN, CONFIG_CHANGED) + - ModuleEventData dataclass + - ModuleEventBus class with subscribe/emit +- `app/modules/migrations.py` - Module migration utilities + - discover_module_migrations() + - get_all_migration_paths() + - get_migration_order() + - validate_migration_names() +- `app/core/observability.py` - Observability framework + - HealthCheckRegistry + - MetricsRegistry (Prometheus placeholder) + - SentryIntegration + - health_router (/health, /metrics, /health/live, /health/ready) +- `app/modules/payments/` - New payments module + - definition.py + - services/payment_service.py + - services/gateway_service.py + - routes/admin.py, vendor.py + - schemas/__init__.py +- `alembic/versions/zc2m3n4o5p6q7_rename_platform_admin_to_tenancy.py` +- `alembic/versions/zd3n4o5p6q7r8_promote_cms_customers_to_core.py` + +--- + +## Dependency Graph + +``` + CORE MODULES + ┌────────────────────────────────────────┐ + │ core ← tenancy ← cms ← customers │ + └────────────────────────────────────────┘ + + OPTIONAL MODULES + ┌─────────────────────────────────────────┐ + │ payments │ + │ ↙ ↘ │ + │ billing orders │ + │ │ + │ inventory │ + │ ↓ │ + │ marketplace │ + │ │ + │ analytics messaging │ + └─────────────────────────────────────────┘ + + INTERNAL MODULES + ┌─────────────────────────────────────────┐ + │ dev-tools monitoring │ + └─────────────────────────────────────────┘ +``` + +--- + +## New Registry Functions + +```python +# Module dictionaries +CORE_MODULES: dict[str, ModuleDefinition] +OPTIONAL_MODULES: dict[str, ModuleDefinition] +INTERNAL_MODULES: dict[str, ModuleDefinition] +MODULES: dict[str, ModuleDefinition] # All combined + +# New helper functions +get_optional_module_codes() -> set[str] +get_internal_modules() -> list[ModuleDefinition] +get_internal_module_codes() -> set[str] +is_core_module(code: str) -> bool +is_internal_module(code: str) -> bool +get_modules_by_tier() -> dict[str, list[ModuleDefinition]] +get_module_tier(code: str) -> str | None # 'core', 'optional', 'internal', None +``` + +--- + +## Module Event System + +```python +from app.modules.events import module_event_bus, ModuleEvent + +# Subscribe to events +@module_event_bus.subscribe(ModuleEvent.ENABLED) +def on_module_enabled(data: ModuleEventData): + print(f"Module {data.module_code} enabled for platform {data.platform_id}") + +# Emit events (done by ModuleService) +module_event_bus.emit_enabled("billing", platform_id=1, user_id=42) +``` + +--- + +## Module Migrations + +Modules can now have their own migrations in `app/modules//migrations/versions/`. + +Migration naming convention: +``` +{module_code}_{sequence}_{description}.py +Example: cms_001_create_content_pages.py +``` + +Alembic automatically discovers module migrations via `get_all_migration_paths()`. + +--- + +## Observability Framework + +```python +from app.core.observability import ( + health_registry, + metrics_registry, + sentry, + init_observability, +) + +# Register health check +@health_registry.register("database") +def check_db() -> HealthCheckResult: + return HealthCheckResult(name="database", status=HealthStatus.HEALTHY) + +# Initialize in lifespan +init_observability( + enable_metrics=True, + sentry_dsn="...", + flower_url="http://flower:5555", +) +``` + +Endpoints: +- `GET /health` - Aggregated health from all checks +- `GET /health/live` - Kubernetes liveness probe +- `GET /health/ready` - Kubernetes readiness probe +- `GET /metrics` - Prometheus metrics +- `GET /health/tools` - External tool URLs (Flower, Grafana) + +--- + +## Payments Module + +New standalone module for payment gateway abstractions: + +```python +from app.modules.payments.services import PaymentService, GatewayService + +payment_service = PaymentService() +result = await payment_service.process_payment( + amount=1000, # cents + currency="EUR", + payment_method_id="pm_xxx", +) + +gateway_service = GatewayService() +gateways = gateway_service.get_available_gateways() +``` + +**Separation from Billing:** +- **Payments**: Gateway abstractions, payment processing, refunds +- **Billing**: Subscriptions, invoices, tier management (uses Payments) +- **Orders**: Checkout, order payments (uses Payments) + +--- + +## Verification Completed + +✅ App starts successfully +✅ Core modules: core, tenancy, cms, customers +✅ Optional modules: payments, billing, inventory, orders, marketplace, analytics, messaging +✅ Internal modules: dev-tools, monitoring +✅ Dependencies validated (billing→payments, orders→payments, marketplace→inventory) +✅ Module event bus working +✅ Observability framework working +✅ Migration discovery working + +--- + +## Next Steps + +1. **Phase 6: Make Customers Self-Contained** (deferred) + - Move models, services to app/modules/customers/ + - Create module-specific migrations + +2. **Database Migrations** + - Run: `alembic upgrade head` + - This will rename platform-admin → tenancy in platform_modules + +3. **Integrate Observability** + - Add health_router to main.py + - Initialize observability in lifespan + +4. **Add Module Health Checks** + - Implement health_check on modules that need monitoring + - Call register_module_health_checks() on startup + +5. **Payments Module Implementation** + - Implement actual Stripe/PayPal gateway logic + - Add Payment, Transaction models + - Create module migrations