feat: implement three-tier module classification and framework layer

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 <noreply@anthropic.com>
This commit is contained in:
2026-01-27 22:02:39 +01:00
parent 9a828999fe
commit 1a52611438
26 changed files with 3084 additions and 67 deletions

View File

@@ -213,6 +213,41 @@ if config.config_file_name is not None:
target_metadata = Base.metadata 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: def run_migrations_offline() -> None:
""" """
Run migrations in 'offline' mode. Run migrations in 'offline' mode.
@@ -229,6 +264,7 @@ def run_migrations_offline() -> None:
target_metadata=target_metadata, target_metadata=target_metadata,
literal_binds=True, literal_binds=True,
dialect_opts={"paramstyle": "named"}, dialect_opts={"paramstyle": "named"},
version_locations=version_locations,
) )
with context.begin_transaction(): with context.begin_transaction():
@@ -249,7 +285,11 @@ def run_migrations_online() -> None:
) )
with connectable.connect() as connection: 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(): with context.begin_transaction():
context.run_migrations() context.run_migrations()

View File

@@ -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"'
"""
)

View File

@@ -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

664
app/core/observability.py Normal file
View File

@@ -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",
]

View File

@@ -4,6 +4,16 @@ Modular Platform Architecture.
This package provides a module system for enabling/disabling feature bundles per platform. 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: Module Hierarchy:
Global (SaaS Provider) Global (SaaS Provider)
└── Platform (Business Product - OMS, Loyalty, etc.) └── Platform (Business Product - OMS, Loyalty, etc.)
@@ -11,7 +21,8 @@ Module Hierarchy:
├── Routes (API + Page routes) ├── Routes (API + Page routes)
├── Services (Business logic) ├── Services (Business logic)
├── Menu Items (Sidebar entries) ├── Menu Items (Sidebar entries)
── Templates (UI components) ── Templates (UI components)
└── Migrations (Module-specific)
Modules vs Features: Modules vs Features:
- Features: Granular capabilities (e.g., analytics_dashboard, letzshop_sync) - Features: Granular capabilities (e.g., analytics_dashboard, letzshop_sync)
@@ -22,7 +33,8 @@ Modules vs Features:
Usage: Usage:
from app.modules import module_service from app.modules import module_service
from app.modules.base import ModuleDefinition 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 # Check if module is enabled for platform
if module_service.is_module_enabled(platform_id, "billing"): if module_service.is_module_enabled(platform_id, "billing"):
@@ -33,15 +45,55 @@ Usage:
# Get all enabled modules for platform # Get all enabled modules for platform
modules = module_service.get_platform_modules(platform_id) 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.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.service import ModuleService, module_service
from app.modules.events import (
ModuleEvent,
ModuleEventData,
ModuleEventBus,
module_event_bus,
)
__all__ = [ __all__ = [
# Core types
"ModuleDefinition", "ModuleDefinition",
# Module dictionaries
"MODULES", "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", "ModuleService",
"module_service", "module_service",
# Events
"ModuleEvent",
"ModuleEventData",
"ModuleEventBus",
"module_event_bus",
] ]

View File

@@ -11,6 +11,12 @@ per platform. Each module contains:
- Models: Database models (optional, for self-contained modules) - Models: Database models (optional, for self-contained modules)
- Schemas: Pydantic schemas (optional, for self-contained modules) - Schemas: Pydantic schemas (optional, for self-contained modules)
- Templates: Jinja2 templates (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: Self-Contained Module Structure:
app/modules/<code>/ app/modules/<code>/
@@ -22,15 +28,19 @@ Self-Contained Module Structure:
├── services/ # Business logic ├── services/ # Business logic
├── models/ # SQLAlchemy models ├── models/ # SQLAlchemy models
├── schemas/ # Pydantic schemas ├── 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 dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Any, Callable
if TYPE_CHECKING: if TYPE_CHECKING:
from fastapi import APIRouter from fastapi import APIRouter
from pydantic import BaseModel
from models.database.admin_menu_config import FrontendType 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. A module groups related functionality that can be enabled/disabled per platform.
Core modules cannot be disabled and are always available. Core modules cannot be disabled and are always available.
Self-contained modules include their own services, models, schemas, and templates. Self-contained modules include their own services, models, schemas, templates,
The path attributes describe where these components are located. and migrations. The path attributes describe where these components are located.
Attributes: Attributes:
# Identity
code: Unique identifier (e.g., "billing", "marketplace") code: Unique identifier (e.g., "billing", "marketplace")
name: Display name (e.g., "Billing & Subscriptions") name: Display name (e.g., "Billing & Subscriptions")
description: Description of what this module provides 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 requires: List of module codes this module depends on
# Components
features: List of feature codes this module provides features: List of feature codes this module provides
menu_items: Dict mapping FrontendType to list of menu item IDs 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_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 admin_router: FastAPI router for admin routes
vendor_router: FastAPI router for vendor 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") # Lifecycle hooks
schemas_path: Path to schemas subpackage (e.g., "app.modules.billing.schemas") on_enable: Called when module is enabled for a platform
templates_path: Path to templates directory (relative to module) on_disable: Called when module is disabled for a platform
exceptions_path: Path to exceptions module (e.g., "app.modules.billing.exceptions") on_startup: Called when application starts (for enabled modules)
locales_path: Path to locales directory (relative to module, e.g., "locales") health_check: Called to check module health status
# Self-contained module paths (optional)
is_self_contained: Whether module uses self-contained structure 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): Example (traditional thin wrapper):
billing_module = ModuleDefinition( 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( cms_module = ModuleDefinition(
code="cms", code="cms",
name="Content Management", name="Content Management",
description="Content pages, media library, and vendor themes.", version="1.0.0",
features=["cms_basic", "cms_custom_pages"], features=["cms_basic", "cms_custom_pages"],
menu_items={ config_schema=CMSConfig,
FrontendType.ADMIN: ["content-pages"], default_config={"max_pages": 100, "enable_seo": True},
FrontendType.VENDOR: ["content-pages", "media"],
},
is_self_contained=True, is_self_contained=True,
services_path="app.modules.cms.services", services_path="app.modules.cms.services",
models_path="app.modules.cms.models", models_path="app.modules.cms.models",
schemas_path="app.modules.cms.schemas", migrations_path="migrations",
templates_path="templates", health_check=lambda: {"status": "healthy"},
exceptions_path="app.modules.cms.exceptions",
locales_path="locales",
) )
""" """
# =========================================================================
# Identity # Identity
# =========================================================================
code: str code: str
name: str name: str
description: str = "" description: str = ""
version: str = "1.0.0"
# =========================================================================
# Dependencies # Dependencies
# =========================================================================
requires: list[str] = field(default_factory=list) requires: list[str] = field(default_factory=list)
# =========================================================================
# Components # Components
# =========================================================================
features: list[str] = field(default_factory=list) features: list[str] = field(default_factory=list)
menu_items: dict[FrontendType, list[str]] = field(default_factory=dict) menu_items: dict[FrontendType, list[str]] = field(default_factory=dict)
permissions: list[str] = field(default_factory=list)
# Status # =========================================================================
# Classification
# =========================================================================
is_core: bool = False 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) # Routes (registered dynamically)
# =========================================================================
admin_router: "APIRouter | None" = None admin_router: "APIRouter | None" = None
vendor_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 is_self_contained: bool = False
services_path: str | None = None services_path: str | None = None
models_path: str | None = None models_path: str | None = None
schemas_path: str | None = None schemas_path: str | None = None
templates_path: str | None = None # Relative to module directory templates_path: str | None = None # Relative to module directory
exceptions_path: str | None = None 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]: def get_menu_items(self, frontend_type: FrontendType) -> list[str]:
"""Get menu item IDs for a specific frontend type.""" """Get menu item IDs for a specific frontend type."""
@@ -135,13 +206,25 @@ class ModuleDefinition:
all_items.update(items) all_items.update(items)
return all_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: def has_feature(self, feature_code: str) -> bool:
"""Check if this module provides a specific feature.""" """Check if this module provides a specific feature."""
return feature_code in self.features return feature_code in self.features
def has_menu_item(self, menu_item_id: str) -> bool: def has_permission(self, permission_code: str) -> bool:
"""Check if this module provides a specific menu item.""" """Check if this module defines a specific permission."""
return menu_item_id in self.get_all_menu_items() return permission_code in self.permissions
# =========================================================================
# Dependency Methods
# =========================================================================
def check_dependencies(self, enabled_modules: set[str]) -> list[str]: 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] 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 # Self-Contained Module Methods
# ========================================================================= # =========================================================================
@@ -166,7 +313,9 @@ class ModuleDefinition:
Returns: Returns:
Path to app/modules/<code>/ Path to app/modules/<code>/
""" """
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: def get_templates_dir(self) -> Path | None:
""" """
@@ -190,6 +339,17 @@ class ModuleDefinition:
return None return None
return self.get_module_dir() / self.locales_path 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: def get_import_path(self, component: str) -> str | None:
""" """
Get the Python import path for a module component. Get the Python import path for a module component.
@@ -237,6 +397,8 @@ class ModuleDefinition:
expected_dirs.append(self.templates_path) expected_dirs.append(self.templates_path)
if self.locales_path: if self.locales_path:
expected_dirs.append(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: for dir_name in expected_dirs:
dir_path = module_dir / dir_name dir_path = module_dir / dir_name
@@ -245,6 +407,28 @@ class ModuleDefinition:
return issues 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: def __hash__(self) -> int:
return hash(self.code) return hash(self.code)
@@ -254,5 +438,6 @@ class ModuleDefinition:
return False return False
def __repr__(self) -> str: def __repr__(self) -> str:
tier = self.get_tier()
sc = ", self_contained" if self.is_self_contained else "" sc = ", self_contained" if self.is_self_contained else ""
return f"<Module({self.code}, core={self.is_core}{sc})>" return f"<Module({self.code}, tier={tier}{sc})>"

View File

@@ -29,13 +29,14 @@ billing_module = ModuleDefinition(
code="billing", code="billing",
name="Billing & Subscriptions", name="Billing & Subscriptions",
description=( description=(
"Subscription tier management, vendor billing, payment processing, " "Platform subscription management, vendor billing, and invoice history. "
"and invoice history. Integrates with Stripe for payment collection." "Uses the payments module for actual payment processing."
), ),
version="1.0.0",
requires=["payments"], # Depends on payments module for payment processing
features=[ features=[
"subscription_management", # Manage subscription tiers "subscription_management", # Manage subscription tiers
"billing_history", # View invoices and payment history "billing_history", # View invoices and payment history
"stripe_integration", # Stripe payment processing
"invoice_generation", # Generate and download invoices "invoice_generation", # Generate and download invoices
"subscription_analytics", # Subscription stats and metrics "subscription_analytics", # Subscription stats and metrics
"trial_management", # Manage vendor trial periods "trial_management", # Manage vendor trial periods

View File

@@ -53,7 +53,7 @@ cms_module = ModuleDefinition(
"media", # Media library "media", # Media library
], ],
}, },
is_core=False, is_core=True, # CMS is a core module - content management is fundamental
# Self-contained module configuration # Self-contained module configuration
is_self_contained=True, is_self_contained=True,
services_path="app.modules.cms.services", services_path="app.modules.cms.services",

View File

@@ -43,7 +43,7 @@ customers_module = ModuleDefinition(
"customers", # Vendor customer list "customers", # Vendor customer list
], ],
}, },
is_core=False, is_core=True, # Customers is a core module - customer data is fundamental
) )

View File

@@ -17,6 +17,7 @@ dev_tools_module = ModuleDefinition(
code="dev-tools", code="dev-tools",
name="Developer Tools", name="Developer Tools",
description="Component library and icon browser for development.", description="Component library and icon browser for development.",
version="1.0.0",
features=[ features=[
"component_library", # UI component browser "component_library", # UI component browser
"icon_browser", # Icon library browser "icon_browser", # Icon library browser
@@ -29,6 +30,7 @@ dev_tools_module = ModuleDefinition(
FrontendType.VENDOR: [], # No vendor menu items FrontendType.VENDOR: [], # No vendor menu items
}, },
is_core=False, is_core=False,
is_internal=True, # Internal module - admin-only, not customer-facing
) )

325
app/modules/events.py Normal file
View File

@@ -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",
]

253
app/modules/migrations.py Normal file
View File

@@ -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/<code>/
└── migrations/
└── versions/
├── <module>_001_initial.py
├── <module>_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",
]

View File

@@ -21,7 +21,8 @@ def _get_admin_router():
monitoring_module = ModuleDefinition( monitoring_module = ModuleDefinition(
code="monitoring", code="monitoring",
name="Platform 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=[ features=[
"application_logs", # Log viewing "application_logs", # Log viewing
"background_tasks", # Task monitoring "background_tasks", # Task monitoring
@@ -29,6 +30,8 @@ monitoring_module = ModuleDefinition(
"capacity_monitoring", # System capacity "capacity_monitoring", # System capacity
"testing_hub", # Test runner "testing_hub", # Test runner
"code_quality", # Code quality tools "code_quality", # Code quality tools
"flower_integration", # Celery Flower link
"grafana_integration", # Grafana dashboard link
], ],
menu_items={ menu_items={
FrontendType.ADMIN: [ FrontendType.ADMIN: [
@@ -42,6 +45,7 @@ monitoring_module = ModuleDefinition(
FrontendType.VENDOR: [], # No vendor menu items FrontendType.VENDOR: [], # No vendor menu items
}, },
is_core=False, is_core=False,
is_internal=True, # Internal module - admin-only, not customer-facing
) )

View File

@@ -29,9 +29,11 @@ orders_module = ModuleDefinition(
code="orders", code="orders",
name="Order Management", name="Order Management",
description=( description=(
"Order processing, fulfillment tracking, order item exceptions, " "Order processing, fulfillment tracking, customer checkout, "
"and bulk order operations." "and bulk order operations. Uses the payments module for checkout."
), ),
version="1.0.0",
requires=["payments"], # Depends on payments module for checkout
features=[ features=[
"order_management", # Basic order CRUD "order_management", # Basic order CRUD
"order_bulk_actions", # Bulk status updates "order_bulk_actions", # Bulk status updates
@@ -40,6 +42,7 @@ orders_module = ModuleDefinition(
"fulfillment_tracking", # Shipping and tracking "fulfillment_tracking", # Shipping and tracking
"shipping_management", # Carrier integration "shipping_management", # Carrier integration
"order_exceptions", # Order item exception handling "order_exceptions", # Order item exception handling
"customer_checkout", # Customer checkout flow
], ],
menu_items={ menu_items={
FrontendType.ADMIN: [ FrontendType.ADMIN: [

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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] = []

View File

@@ -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"]

View File

@@ -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,
}

View File

@@ -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}

View File

@@ -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",
]

View File

@@ -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"]

View File

@@ -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",
]

View File

@@ -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",
]

View File

@@ -2,14 +2,29 @@
""" """
Module registry defining all available platform modules. Module registry defining all available platform modules.
Each module bundles related features and menu items that can be The module system uses a three-tier classification:
enabled/disabled per platform. Core modules cannot be disabled.
Module Granularity (Medium - ~12 modules): 1. CORE MODULES (4) - Always enabled, cannot be disabled
Matches menu sections for intuitive mapping between modules and UI. - 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: 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.) - Extracted modules: Imported from app/modules/{module}/ (billing, etc.)
As modules are extracted to their own directories, they are imported 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 # Import extracted modules
from app.modules.billing.definition import billing_module 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.inventory.definition import inventory_module
from app.modules.marketplace.definition import marketplace_module from app.modules.marketplace.definition import marketplace_module
from app.modules.orders.definition import orders_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: dict[str, ModuleDefinition] = {
# =========================================================================
# Core Modules (Always Enabled)
# =========================================================================
"core": ModuleDefinition( "core": ModuleDefinition(
code="core", code="core",
name="Core Platform", name="Core Platform",
@@ -65,48 +78,67 @@ MODULES: dict[str, ModuleDefinition] = {
], ],
}, },
), ),
"platform-admin": ModuleDefinition( "tenancy": ModuleDefinition(
code="platform-admin", code="tenancy",
name="Platform Administration", name="Tenancy Management",
description="Company, vendor, and admin user management. Required for multi-tenant operation.", description="Platform, company, vendor, and admin user management. Required for multi-tenant operation.",
is_core=True, is_core=True,
features=[ features=[
"platform_management",
"company_management", "company_management",
"vendor_management", "vendor_management",
"admin_user_management", "admin_user_management",
"platform_management",
], ],
menu_items={ menu_items={
FrontendType.ADMIN: [ FrontendType.ADMIN: [
"admin-users", "platforms",
"companies", "companies",
"vendors", "vendors",
"platforms", "admin-users",
], ],
FrontendType.VENDOR: [ FrontendType.VENDOR: [
"team", "team",
], ],
}, },
), ),
# ========================================================================= # CMS module - imported from app/modules/cms/
# Optional Modules "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/ # Billing module - imported from app/modules/billing/
# Platform subscriptions, vendor invoices (requires: payments)
"billing": billing_module, "billing": billing_module,
# Inventory module - imported from app/modules/inventory/ # Inventory module - imported from app/modules/inventory/
"inventory": inventory_module, "inventory": inventory_module,
# Orders module - imported from app/modules/orders/ # Orders module - imported from app/modules/orders/
# Order management, customer checkout (requires: payments)
"orders": orders_module, "orders": orders_module,
# Marketplace module - imported from app/modules/marketplace/ # Marketplace module - imported from app/modules/marketplace/
# Letzshop integration (requires: inventory)
"marketplace": marketplace_module, "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 module - imported from app/modules/analytics/
"analytics": analytics_module, "analytics": analytics_module,
# Messaging module - imported from app/modules/messaging/ # Messaging module - imported from app/modules/messaging/
"messaging": messaging_module, "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 module - imported from app/modules/dev_tools/
"dev-tools": dev_tools_module, "dev-tools": dev_tools_module,
# Monitoring module - imported from app/modules/monitoring/ # 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 # Helper Functions
# ============================================================================= # =============================================================================
@@ -126,17 +169,32 @@ def get_module(code: str) -> ModuleDefinition | None:
def get_core_modules() -> list[ModuleDefinition]: def get_core_modules() -> list[ModuleDefinition]:
"""Get all core modules (cannot be disabled).""" """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]: def get_core_module_codes() -> set[str]:
"""Get codes of all core modules.""" """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]: def get_optional_modules() -> list[ModuleDefinition]:
"""Get all optional modules (can be enabled/disabled).""" """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]: def get_all_module_codes() -> set[str]:
@@ -144,6 +202,16 @@ def get_all_module_codes() -> set[str]:
return set(MODULES.keys()) 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: def get_menu_item_module(menu_item_id: str, frontend_type: FrontendType) -> str | None:
""" """
Find which module provides a specific menu item. Find which module provides a specific menu item.
@@ -202,6 +270,39 @@ def validate_module_dependencies() -> list[str]:
return errors 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) # Validate dependencies on import (development check)
_validation_errors = validate_module_dependencies() _validation_errors = validate_module_dependencies()
if _validation_errors: if _validation_errors:
@@ -212,13 +313,28 @@ if _validation_errors:
__all__ = [ __all__ = [
# Module dictionaries
"MODULES", "MODULES",
"CORE_MODULES",
"OPTIONAL_MODULES",
"INTERNAL_MODULES",
# Module retrieval
"get_module", "get_module",
"get_core_modules", "get_core_modules",
"get_core_module_codes", "get_core_module_codes",
"get_optional_modules", "get_optional_modules",
"get_optional_module_codes",
"get_internal_modules",
"get_internal_module_codes",
"get_all_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_menu_item_module",
"get_feature_module", "get_feature_module",
# Validation
"validate_module_dependencies", "validate_module_dependencies",
] ]

View File

@@ -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/<code>/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