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:
@@ -213,6 +213,41 @@ if config.config_file_name is not None:
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MODULE MIGRATIONS DISCOVERY
|
||||
# ============================================================================
|
||||
# Discover migration paths from self-contained modules.
|
||||
# Each module can have its own migrations/ directory.
|
||||
# ============================================================================
|
||||
|
||||
def get_version_locations() -> list[str]:
|
||||
"""
|
||||
Get all version locations including module migrations.
|
||||
|
||||
Returns:
|
||||
List of paths to migration version directories
|
||||
"""
|
||||
try:
|
||||
from app.modules.migrations import get_all_migration_paths
|
||||
|
||||
paths = get_all_migration_paths()
|
||||
locations = [str(p) for p in paths if p.exists()]
|
||||
|
||||
if len(locations) > 1:
|
||||
print(f"[ALEMBIC] Found {len(locations)} migration locations:")
|
||||
for loc in locations:
|
||||
print(f" - {loc}")
|
||||
|
||||
return locations
|
||||
except ImportError:
|
||||
# Fallback if module migrations not available
|
||||
return ["alembic/versions"]
|
||||
|
||||
|
||||
# Get version locations for multi-directory support
|
||||
version_locations = get_version_locations()
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""
|
||||
Run migrations in 'offline' mode.
|
||||
@@ -229,6 +264,7 @@ def run_migrations_offline() -> None:
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
version_locations=version_locations,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
@@ -249,7 +285,11 @@ def run_migrations_online() -> None:
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
version_locations=version_locations,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
@@ -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"'
|
||||
"""
|
||||
)
|
||||
@@ -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
664
app/core/observability.py
Normal 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",
|
||||
]
|
||||
@@ -4,6 +4,16 @@ Modular Platform Architecture.
|
||||
|
||||
This package provides a module system for enabling/disabling feature bundles per platform.
|
||||
|
||||
Three-Tier Classification:
|
||||
1. CORE MODULES (4) - Always enabled, cannot be disabled
|
||||
- core, tenancy, cms, customers
|
||||
|
||||
2. OPTIONAL MODULES (7) - Can be enabled/disabled per platform
|
||||
- payments, billing, inventory, orders, marketplace, analytics, messaging
|
||||
|
||||
3. INTERNAL MODULES (2) - Admin-only tools, not customer-facing
|
||||
- dev-tools, monitoring
|
||||
|
||||
Module Hierarchy:
|
||||
Global (SaaS Provider)
|
||||
└── Platform (Business Product - OMS, Loyalty, etc.)
|
||||
@@ -11,7 +21,8 @@ Module Hierarchy:
|
||||
├── Routes (API + Page routes)
|
||||
├── Services (Business logic)
|
||||
├── Menu Items (Sidebar entries)
|
||||
└── Templates (UI components)
|
||||
├── Templates (UI components)
|
||||
└── Migrations (Module-specific)
|
||||
|
||||
Modules vs Features:
|
||||
- Features: Granular capabilities (e.g., analytics_dashboard, letzshop_sync)
|
||||
@@ -22,7 +33,8 @@ Modules vs Features:
|
||||
Usage:
|
||||
from app.modules import module_service
|
||||
from app.modules.base import ModuleDefinition
|
||||
from app.modules.registry import MODULES
|
||||
from app.modules.registry import MODULES, CORE_MODULES, OPTIONAL_MODULES
|
||||
from app.modules.events import module_event_bus, ModuleEvent
|
||||
|
||||
# Check if module is enabled for platform
|
||||
if module_service.is_module_enabled(platform_id, "billing"):
|
||||
@@ -33,15 +45,55 @@ Usage:
|
||||
|
||||
# Get all enabled modules for platform
|
||||
modules = module_service.get_platform_modules(platform_id)
|
||||
|
||||
# Subscribe to module events
|
||||
@module_event_bus.subscribe(ModuleEvent.ENABLED)
|
||||
def on_enabled(data):
|
||||
print(f"Module {data.module_code} enabled")
|
||||
"""
|
||||
|
||||
from app.modules.base import ModuleDefinition
|
||||
from app.modules.registry import MODULES
|
||||
from app.modules.registry import (
|
||||
MODULES,
|
||||
CORE_MODULES,
|
||||
OPTIONAL_MODULES,
|
||||
INTERNAL_MODULES,
|
||||
get_core_module_codes,
|
||||
get_optional_module_codes,
|
||||
get_internal_module_codes,
|
||||
get_module_tier,
|
||||
is_core_module,
|
||||
is_internal_module,
|
||||
)
|
||||
from app.modules.service import ModuleService, module_service
|
||||
from app.modules.events import (
|
||||
ModuleEvent,
|
||||
ModuleEventData,
|
||||
ModuleEventBus,
|
||||
module_event_bus,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Core types
|
||||
"ModuleDefinition",
|
||||
# Module dictionaries
|
||||
"MODULES",
|
||||
"CORE_MODULES",
|
||||
"OPTIONAL_MODULES",
|
||||
"INTERNAL_MODULES",
|
||||
# Helper functions
|
||||
"get_core_module_codes",
|
||||
"get_optional_module_codes",
|
||||
"get_internal_module_codes",
|
||||
"get_module_tier",
|
||||
"is_core_module",
|
||||
"is_internal_module",
|
||||
# Service
|
||||
"ModuleService",
|
||||
"module_service",
|
||||
# Events
|
||||
"ModuleEvent",
|
||||
"ModuleEventData",
|
||||
"ModuleEventBus",
|
||||
"module_event_bus",
|
||||
]
|
||||
|
||||
@@ -11,6 +11,12 @@ per platform. Each module contains:
|
||||
- Models: Database models (optional, for self-contained modules)
|
||||
- Schemas: Pydantic schemas (optional, for self-contained modules)
|
||||
- Templates: Jinja2 templates (optional, for self-contained modules)
|
||||
- Migrations: Database migrations (optional, for self-contained modules)
|
||||
|
||||
Module Classification:
|
||||
- Core modules: Always enabled, cannot be disabled (core, tenancy, cms, customers)
|
||||
- Optional modules: Can be enabled/disabled per platform
|
||||
- Internal modules: Admin-only tools, not customer-facing (dev-tools, monitoring)
|
||||
|
||||
Self-Contained Module Structure:
|
||||
app/modules/<code>/
|
||||
@@ -22,15 +28,19 @@ Self-Contained Module Structure:
|
||||
├── services/ # Business logic
|
||||
├── models/ # SQLAlchemy models
|
||||
├── schemas/ # Pydantic schemas
|
||||
└── templates/ # Jinja2 templates (namespaced)
|
||||
├── migrations/ # Alembic migrations for this module
|
||||
│ └── versions/ # Migration scripts
|
||||
├── templates/ # Jinja2 templates (namespaced)
|
||||
└── locales/ # Translation files
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from models.database.admin_menu_config import FrontendType
|
||||
|
||||
@@ -43,26 +53,51 @@ class ModuleDefinition:
|
||||
A module groups related functionality that can be enabled/disabled per platform.
|
||||
Core modules cannot be disabled and are always available.
|
||||
|
||||
Self-contained modules include their own services, models, schemas, and templates.
|
||||
The path attributes describe where these components are located.
|
||||
Self-contained modules include their own services, models, schemas, templates,
|
||||
and migrations. The path attributes describe where these components are located.
|
||||
|
||||
Attributes:
|
||||
# Identity
|
||||
code: Unique identifier (e.g., "billing", "marketplace")
|
||||
name: Display name (e.g., "Billing & Subscriptions")
|
||||
description: Description of what this module provides
|
||||
version: Semantic version of the module (e.g., "1.0.0")
|
||||
|
||||
# Dependencies
|
||||
requires: List of module codes this module depends on
|
||||
|
||||
# Components
|
||||
features: List of feature codes this module provides
|
||||
menu_items: Dict mapping FrontendType to list of menu item IDs
|
||||
permissions: List of permission codes this module defines
|
||||
|
||||
# Classification
|
||||
is_core: Core modules cannot be disabled
|
||||
is_internal: Internal modules are admin-only (not customer-facing)
|
||||
|
||||
# Configuration
|
||||
config_schema: Pydantic model class for module configuration
|
||||
default_config: Default configuration values
|
||||
|
||||
# Routes
|
||||
admin_router: FastAPI router for admin routes
|
||||
vendor_router: FastAPI router for vendor routes
|
||||
services_path: Path to services subpackage (e.g., "app.modules.billing.services")
|
||||
models_path: Path to models subpackage (e.g., "app.modules.billing.models")
|
||||
schemas_path: Path to schemas subpackage (e.g., "app.modules.billing.schemas")
|
||||
templates_path: Path to templates directory (relative to module)
|
||||
exceptions_path: Path to exceptions module (e.g., "app.modules.billing.exceptions")
|
||||
locales_path: Path to locales directory (relative to module, e.g., "locales")
|
||||
|
||||
# Lifecycle hooks
|
||||
on_enable: Called when module is enabled for a platform
|
||||
on_disable: Called when module is disabled for a platform
|
||||
on_startup: Called when application starts (for enabled modules)
|
||||
health_check: Called to check module health status
|
||||
|
||||
# Self-contained module paths (optional)
|
||||
is_self_contained: Whether module uses self-contained structure
|
||||
services_path: Path to services subpackage
|
||||
models_path: Path to models subpackage
|
||||
schemas_path: Path to schemas subpackage
|
||||
templates_path: Path to templates directory (relative to module)
|
||||
exceptions_path: Path to exceptions module
|
||||
locales_path: Path to locales directory (relative to module)
|
||||
migrations_path: Path to migrations directory (relative to module)
|
||||
|
||||
Example (traditional thin wrapper):
|
||||
billing_module = ModuleDefinition(
|
||||
@@ -76,53 +111,89 @@ class ModuleDefinition:
|
||||
},
|
||||
)
|
||||
|
||||
Example (self-contained module):
|
||||
Example (self-contained module with configuration):
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class CMSConfig(BaseModel):
|
||||
max_pages: int = Field(default=100, ge=1)
|
||||
enable_seo: bool = True
|
||||
|
||||
cms_module = ModuleDefinition(
|
||||
code="cms",
|
||||
name="Content Management",
|
||||
description="Content pages, media library, and vendor themes.",
|
||||
version="1.0.0",
|
||||
features=["cms_basic", "cms_custom_pages"],
|
||||
menu_items={
|
||||
FrontendType.ADMIN: ["content-pages"],
|
||||
FrontendType.VENDOR: ["content-pages", "media"],
|
||||
},
|
||||
config_schema=CMSConfig,
|
||||
default_config={"max_pages": 100, "enable_seo": True},
|
||||
is_self_contained=True,
|
||||
services_path="app.modules.cms.services",
|
||||
models_path="app.modules.cms.models",
|
||||
schemas_path="app.modules.cms.schemas",
|
||||
templates_path="templates",
|
||||
exceptions_path="app.modules.cms.exceptions",
|
||||
locales_path="locales",
|
||||
migrations_path="migrations",
|
||||
health_check=lambda: {"status": "healthy"},
|
||||
)
|
||||
"""
|
||||
|
||||
# =========================================================================
|
||||
# Identity
|
||||
# =========================================================================
|
||||
code: str
|
||||
name: str
|
||||
description: str = ""
|
||||
version: str = "1.0.0"
|
||||
|
||||
# =========================================================================
|
||||
# Dependencies
|
||||
# =========================================================================
|
||||
requires: list[str] = field(default_factory=list)
|
||||
|
||||
# =========================================================================
|
||||
# Components
|
||||
# =========================================================================
|
||||
features: list[str] = field(default_factory=list)
|
||||
menu_items: dict[FrontendType, list[str]] = field(default_factory=dict)
|
||||
permissions: list[str] = field(default_factory=list)
|
||||
|
||||
# Status
|
||||
# =========================================================================
|
||||
# Classification
|
||||
# =========================================================================
|
||||
is_core: bool = False
|
||||
is_internal: bool = False
|
||||
|
||||
# =========================================================================
|
||||
# Configuration
|
||||
# =========================================================================
|
||||
config_schema: "type[BaseModel] | None" = None
|
||||
default_config: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# =========================================================================
|
||||
# Routes (registered dynamically)
|
||||
# =========================================================================
|
||||
admin_router: "APIRouter | None" = None
|
||||
vendor_router: "APIRouter | None" = None
|
||||
|
||||
# Self-contained module paths (optional)
|
||||
# =========================================================================
|
||||
# Lifecycle Hooks
|
||||
# =========================================================================
|
||||
on_enable: Callable[[int], None] | None = None # Called with platform_id
|
||||
on_disable: Callable[[int], None] | None = None # Called with platform_id
|
||||
on_startup: Callable[[], None] | None = None # Called on app startup
|
||||
health_check: Callable[[], dict[str, Any]] | None = None # Returns health status
|
||||
|
||||
# =========================================================================
|
||||
# Self-Contained Module Paths (optional)
|
||||
# =========================================================================
|
||||
is_self_contained: bool = False
|
||||
services_path: str | None = None
|
||||
models_path: str | None = None
|
||||
schemas_path: str | None = None
|
||||
templates_path: str | None = None # Relative to module directory
|
||||
exceptions_path: str | None = None
|
||||
locales_path: str | None = None # Relative to module directory, e.g., "locales"
|
||||
locales_path: str | None = None # Relative to module directory
|
||||
migrations_path: str | None = None # Relative to module directory, e.g., "migrations"
|
||||
|
||||
# =========================================================================
|
||||
# Menu Item Methods
|
||||
# =========================================================================
|
||||
|
||||
def get_menu_items(self, frontend_type: FrontendType) -> list[str]:
|
||||
"""Get menu item IDs for a specific frontend type."""
|
||||
@@ -135,13 +206,25 @@ class ModuleDefinition:
|
||||
all_items.update(items)
|
||||
return all_items
|
||||
|
||||
def has_menu_item(self, menu_item_id: str) -> bool:
|
||||
"""Check if this module provides a specific menu item."""
|
||||
return menu_item_id in self.get_all_menu_items()
|
||||
|
||||
# =========================================================================
|
||||
# Feature Methods
|
||||
# =========================================================================
|
||||
|
||||
def has_feature(self, feature_code: str) -> bool:
|
||||
"""Check if this module provides a specific feature."""
|
||||
return feature_code in self.features
|
||||
|
||||
def has_menu_item(self, menu_item_id: str) -> bool:
|
||||
"""Check if this module provides a specific menu item."""
|
||||
return menu_item_id in self.get_all_menu_items()
|
||||
def has_permission(self, permission_code: str) -> bool:
|
||||
"""Check if this module defines a specific permission."""
|
||||
return permission_code in self.permissions
|
||||
|
||||
# =========================================================================
|
||||
# Dependency Methods
|
||||
# =========================================================================
|
||||
|
||||
def check_dependencies(self, enabled_modules: set[str]) -> list[str]:
|
||||
"""
|
||||
@@ -155,6 +238,70 @@ class ModuleDefinition:
|
||||
"""
|
||||
return [req for req in self.requires if req not in enabled_modules]
|
||||
|
||||
# =========================================================================
|
||||
# Configuration Methods
|
||||
# =========================================================================
|
||||
|
||||
def validate_config(self, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Validate configuration against the schema.
|
||||
|
||||
Args:
|
||||
config: Configuration dict to validate
|
||||
|
||||
Returns:
|
||||
Validated configuration dict
|
||||
|
||||
Raises:
|
||||
ValidationError: If configuration is invalid
|
||||
"""
|
||||
if self.config_schema is None:
|
||||
return config
|
||||
|
||||
# Merge with defaults
|
||||
merged = {**self.default_config, **config}
|
||||
# Validate using Pydantic model
|
||||
validated = self.config_schema(**merged)
|
||||
return validated.model_dump()
|
||||
|
||||
def get_default_config(self) -> dict[str, Any]:
|
||||
"""Get the default configuration for this module."""
|
||||
if self.config_schema is None:
|
||||
return self.default_config.copy()
|
||||
|
||||
# Use Pydantic model defaults
|
||||
return self.config_schema().model_dump()
|
||||
|
||||
# =========================================================================
|
||||
# Lifecycle Methods
|
||||
# =========================================================================
|
||||
|
||||
def run_on_enable(self, platform_id: int) -> None:
|
||||
"""Run the on_enable hook if defined."""
|
||||
if self.on_enable:
|
||||
self.on_enable(platform_id)
|
||||
|
||||
def run_on_disable(self, platform_id: int) -> None:
|
||||
"""Run the on_disable hook if defined."""
|
||||
if self.on_disable:
|
||||
self.on_disable(platform_id)
|
||||
|
||||
def run_on_startup(self) -> None:
|
||||
"""Run the on_startup hook if defined."""
|
||||
if self.on_startup:
|
||||
self.on_startup()
|
||||
|
||||
def get_health_status(self) -> dict[str, Any]:
|
||||
"""
|
||||
Get the health status of this module.
|
||||
|
||||
Returns:
|
||||
Dict with at least a 'status' key ('healthy', 'degraded', 'unhealthy')
|
||||
"""
|
||||
if self.health_check:
|
||||
return self.health_check()
|
||||
return {"status": "healthy", "message": "No health check defined"}
|
||||
|
||||
# =========================================================================
|
||||
# Self-Contained Module Methods
|
||||
# =========================================================================
|
||||
@@ -166,7 +313,9 @@ class ModuleDefinition:
|
||||
Returns:
|
||||
Path to app/modules/<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:
|
||||
"""
|
||||
@@ -190,6 +339,17 @@ class ModuleDefinition:
|
||||
return None
|
||||
return self.get_module_dir() / self.locales_path
|
||||
|
||||
def get_migrations_dir(self) -> Path | None:
|
||||
"""
|
||||
Get the filesystem path to this module's migrations directory.
|
||||
|
||||
Returns:
|
||||
Path to migrations directory, or None if not configured
|
||||
"""
|
||||
if not self.migrations_path:
|
||||
return None
|
||||
return self.get_module_dir() / self.migrations_path
|
||||
|
||||
def get_import_path(self, component: str) -> str | None:
|
||||
"""
|
||||
Get the Python import path for a module component.
|
||||
@@ -237,6 +397,8 @@ class ModuleDefinition:
|
||||
expected_dirs.append(self.templates_path)
|
||||
if self.locales_path:
|
||||
expected_dirs.append(self.locales_path)
|
||||
if self.migrations_path:
|
||||
expected_dirs.append(self.migrations_path)
|
||||
|
||||
for dir_name in expected_dirs:
|
||||
dir_path = module_dir / dir_name
|
||||
@@ -245,6 +407,28 @@ class ModuleDefinition:
|
||||
|
||||
return issues
|
||||
|
||||
# =========================================================================
|
||||
# Classification Methods
|
||||
# =========================================================================
|
||||
|
||||
def get_tier(self) -> str:
|
||||
"""
|
||||
Get the tier classification of this module.
|
||||
|
||||
Returns:
|
||||
'core', 'internal', or 'optional'
|
||||
"""
|
||||
if self.is_core:
|
||||
return "core"
|
||||
elif self.is_internal:
|
||||
return "internal"
|
||||
else:
|
||||
return "optional"
|
||||
|
||||
# =========================================================================
|
||||
# Magic Methods
|
||||
# =========================================================================
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.code)
|
||||
|
||||
@@ -254,5 +438,6 @@ class ModuleDefinition:
|
||||
return False
|
||||
|
||||
def __repr__(self) -> str:
|
||||
tier = self.get_tier()
|
||||
sc = ", self_contained" if self.is_self_contained else ""
|
||||
return f"<Module({self.code}, core={self.is_core}{sc})>"
|
||||
return f"<Module({self.code}, tier={tier}{sc})>"
|
||||
|
||||
@@ -29,13 +29,14 @@ billing_module = ModuleDefinition(
|
||||
code="billing",
|
||||
name="Billing & Subscriptions",
|
||||
description=(
|
||||
"Subscription tier management, vendor billing, payment processing, "
|
||||
"and invoice history. Integrates with Stripe for payment collection."
|
||||
"Platform subscription management, vendor billing, and invoice history. "
|
||||
"Uses the payments module for actual payment processing."
|
||||
),
|
||||
version="1.0.0",
|
||||
requires=["payments"], # Depends on payments module for payment processing
|
||||
features=[
|
||||
"subscription_management", # Manage subscription tiers
|
||||
"billing_history", # View invoices and payment history
|
||||
"stripe_integration", # Stripe payment processing
|
||||
"invoice_generation", # Generate and download invoices
|
||||
"subscription_analytics", # Subscription stats and metrics
|
||||
"trial_management", # Manage vendor trial periods
|
||||
|
||||
@@ -53,7 +53,7 @@ cms_module = ModuleDefinition(
|
||||
"media", # Media library
|
||||
],
|
||||
},
|
||||
is_core=False,
|
||||
is_core=True, # CMS is a core module - content management is fundamental
|
||||
# Self-contained module configuration
|
||||
is_self_contained=True,
|
||||
services_path="app.modules.cms.services",
|
||||
|
||||
@@ -43,7 +43,7 @@ customers_module = ModuleDefinition(
|
||||
"customers", # Vendor customer list
|
||||
],
|
||||
},
|
||||
is_core=False,
|
||||
is_core=True, # Customers is a core module - customer data is fundamental
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ dev_tools_module = ModuleDefinition(
|
||||
code="dev-tools",
|
||||
name="Developer Tools",
|
||||
description="Component library and icon browser for development.",
|
||||
version="1.0.0",
|
||||
features=[
|
||||
"component_library", # UI component browser
|
||||
"icon_browser", # Icon library browser
|
||||
@@ -29,6 +30,7 @@ dev_tools_module = ModuleDefinition(
|
||||
FrontendType.VENDOR: [], # No vendor menu items
|
||||
},
|
||||
is_core=False,
|
||||
is_internal=True, # Internal module - admin-only, not customer-facing
|
||||
)
|
||||
|
||||
|
||||
|
||||
325
app/modules/events.py
Normal file
325
app/modules/events.py
Normal 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
253
app/modules/migrations.py
Normal 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",
|
||||
]
|
||||
@@ -21,7 +21,8 @@ def _get_admin_router():
|
||||
monitoring_module = ModuleDefinition(
|
||||
code="monitoring",
|
||||
name="Platform Monitoring",
|
||||
description="Logs, background tasks, imports, and system health.",
|
||||
description="Logs, background tasks, imports, system health, Flower, and Grafana integration.",
|
||||
version="1.0.0",
|
||||
features=[
|
||||
"application_logs", # Log viewing
|
||||
"background_tasks", # Task monitoring
|
||||
@@ -29,6 +30,8 @@ monitoring_module = ModuleDefinition(
|
||||
"capacity_monitoring", # System capacity
|
||||
"testing_hub", # Test runner
|
||||
"code_quality", # Code quality tools
|
||||
"flower_integration", # Celery Flower link
|
||||
"grafana_integration", # Grafana dashboard link
|
||||
],
|
||||
menu_items={
|
||||
FrontendType.ADMIN: [
|
||||
@@ -42,6 +45,7 @@ monitoring_module = ModuleDefinition(
|
||||
FrontendType.VENDOR: [], # No vendor menu items
|
||||
},
|
||||
is_core=False,
|
||||
is_internal=True, # Internal module - admin-only, not customer-facing
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -29,9 +29,11 @@ orders_module = ModuleDefinition(
|
||||
code="orders",
|
||||
name="Order Management",
|
||||
description=(
|
||||
"Order processing, fulfillment tracking, order item exceptions, "
|
||||
"and bulk order operations."
|
||||
"Order processing, fulfillment tracking, customer checkout, "
|
||||
"and bulk order operations. Uses the payments module for checkout."
|
||||
),
|
||||
version="1.0.0",
|
||||
requires=["payments"], # Depends on payments module for checkout
|
||||
features=[
|
||||
"order_management", # Basic order CRUD
|
||||
"order_bulk_actions", # Bulk status updates
|
||||
@@ -40,6 +42,7 @@ orders_module = ModuleDefinition(
|
||||
"fulfillment_tracking", # Shipping and tracking
|
||||
"shipping_management", # Carrier integration
|
||||
"order_exceptions", # Order item exception handling
|
||||
"customer_checkout", # Customer checkout flow
|
||||
],
|
||||
menu_items={
|
||||
FrontendType.ADMIN: [
|
||||
|
||||
32
app/modules/payments/__init__.py
Normal file
32
app/modules/payments/__init__.py
Normal 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"]
|
||||
79
app/modules/payments/definition.py
Normal file
79
app/modules/payments/definition.py
Normal 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"]
|
||||
11
app/modules/payments/models/__init__.py
Normal file
11
app/modules/payments/models/__init__.py
Normal 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] = []
|
||||
7
app/modules/payments/routes/__init__.py
Normal file
7
app/modules/payments/routes/__init__.py
Normal 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"]
|
||||
44
app/modules/payments/routes/admin.py
Normal file
44
app/modules/payments/routes/admin.py
Normal 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,
|
||||
}
|
||||
40
app/modules/payments/routes/vendor.py
Normal file
40
app/modules/payments/routes/vendor.py
Normal 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}
|
||||
93
app/modules/payments/schemas/__init__.py
Normal file
93
app/modules/payments/schemas/__init__.py
Normal 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",
|
||||
]
|
||||
13
app/modules/payments/services/__init__.py
Normal file
13
app/modules/payments/services/__init__.py
Normal 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"]
|
||||
351
app/modules/payments/services/gateway_service.py
Normal file
351
app/modules/payments/services/gateway_service.py
Normal 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",
|
||||
]
|
||||
232
app/modules/payments/services/payment_service.py
Normal file
232
app/modules/payments/services/payment_service.py
Normal 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",
|
||||
]
|
||||
@@ -2,14 +2,29 @@
|
||||
"""
|
||||
Module registry defining all available platform modules.
|
||||
|
||||
Each module bundles related features and menu items that can be
|
||||
enabled/disabled per platform. Core modules cannot be disabled.
|
||||
The module system uses a three-tier classification:
|
||||
|
||||
Module Granularity (Medium - ~12 modules):
|
||||
Matches menu sections for intuitive mapping between modules and UI.
|
||||
1. CORE MODULES (4) - Always enabled, cannot be disabled
|
||||
- core: Dashboard, settings, profile
|
||||
- tenancy: Platform, company, vendor, admin user management
|
||||
- cms: Content pages, media library, themes
|
||||
- customers: Customer database, profiles, segmentation
|
||||
|
||||
2. OPTIONAL MODULES (7) - Can be enabled/disabled per platform
|
||||
- payments: Payment gateway integrations (Stripe, PayPal, etc.)
|
||||
- billing: Platform subscriptions, vendor invoices (requires: payments)
|
||||
- inventory: Stock management, locations
|
||||
- orders: Order management, customer checkout (requires: payments)
|
||||
- marketplace: Letzshop integration (requires: inventory)
|
||||
- analytics: Reports, dashboards
|
||||
- messaging: Messages, notifications
|
||||
|
||||
3. INTERNAL MODULES (2) - Admin-only tools, not customer-facing
|
||||
- dev-tools: Component library, icons
|
||||
- monitoring: Logs, background tasks, Flower link, Grafana dashboards
|
||||
|
||||
Module Structure:
|
||||
- Inline modules: Defined directly in this file (core, platform-admin, etc.)
|
||||
- Inline modules: Defined directly in this file (core, tenancy)
|
||||
- Extracted modules: Imported from app/modules/{module}/ (billing, etc.)
|
||||
|
||||
As modules are extracted to their own directories, they are imported
|
||||
@@ -21,6 +36,7 @@ from models.database.admin_menu_config import FrontendType
|
||||
|
||||
# Import extracted modules
|
||||
from app.modules.billing.definition import billing_module
|
||||
from app.modules.payments.definition import payments_module
|
||||
from app.modules.inventory.definition import inventory_module
|
||||
from app.modules.marketplace.definition import marketplace_module
|
||||
from app.modules.orders.definition import orders_module
|
||||
@@ -33,13 +49,10 @@ from app.modules.monitoring.definition import monitoring_module
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Module Definitions
|
||||
# Core Modules (Always Enabled, Cannot Be Disabled)
|
||||
# =============================================================================
|
||||
|
||||
MODULES: dict[str, ModuleDefinition] = {
|
||||
# =========================================================================
|
||||
# Core Modules (Always Enabled)
|
||||
# =========================================================================
|
||||
CORE_MODULES: dict[str, ModuleDefinition] = {
|
||||
"core": ModuleDefinition(
|
||||
code="core",
|
||||
name="Core Platform",
|
||||
@@ -65,48 +78,67 @@ MODULES: dict[str, ModuleDefinition] = {
|
||||
],
|
||||
},
|
||||
),
|
||||
"platform-admin": ModuleDefinition(
|
||||
code="platform-admin",
|
||||
name="Platform Administration",
|
||||
description="Company, vendor, and admin user management. Required for multi-tenant operation.",
|
||||
"tenancy": ModuleDefinition(
|
||||
code="tenancy",
|
||||
name="Tenancy Management",
|
||||
description="Platform, company, vendor, and admin user management. Required for multi-tenant operation.",
|
||||
is_core=True,
|
||||
features=[
|
||||
"platform_management",
|
||||
"company_management",
|
||||
"vendor_management",
|
||||
"admin_user_management",
|
||||
"platform_management",
|
||||
],
|
||||
menu_items={
|
||||
FrontendType.ADMIN: [
|
||||
"admin-users",
|
||||
"platforms",
|
||||
"companies",
|
||||
"vendors",
|
||||
"platforms",
|
||||
"admin-users",
|
||||
],
|
||||
FrontendType.VENDOR: [
|
||||
"team",
|
||||
],
|
||||
},
|
||||
),
|
||||
# =========================================================================
|
||||
# Optional Modules
|
||||
# =========================================================================
|
||||
# CMS module - imported from app/modules/cms/
|
||||
"cms": cms_module,
|
||||
# Customers module - imported from app/modules/customers/
|
||||
"customers": customers_module,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Optional Modules (Can Be Enabled/Disabled Per Platform)
|
||||
# =============================================================================
|
||||
|
||||
OPTIONAL_MODULES: dict[str, ModuleDefinition] = {
|
||||
# Payments module - imported from app/modules/payments/
|
||||
# Gateway integrations (Stripe, PayPal, etc.)
|
||||
"payments": payments_module,
|
||||
# Billing module - imported from app/modules/billing/
|
||||
# Platform subscriptions, vendor invoices (requires: payments)
|
||||
"billing": billing_module,
|
||||
# Inventory module - imported from app/modules/inventory/
|
||||
"inventory": inventory_module,
|
||||
# Orders module - imported from app/modules/orders/
|
||||
# Order management, customer checkout (requires: payments)
|
||||
"orders": orders_module,
|
||||
# Marketplace module - imported from app/modules/marketplace/
|
||||
# Letzshop integration (requires: inventory)
|
||||
"marketplace": marketplace_module,
|
||||
# Customers module - imported from app/modules/customers/
|
||||
"customers": customers_module,
|
||||
# CMS module - imported from app/modules/cms/
|
||||
"cms": cms_module,
|
||||
# Analytics module - imported from app/modules/analytics/
|
||||
"analytics": analytics_module,
|
||||
# Messaging module - imported from app/modules/messaging/
|
||||
"messaging": messaging_module,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Internal Modules (Admin-Only, Not Customer-Facing)
|
||||
# =============================================================================
|
||||
|
||||
INTERNAL_MODULES: dict[str, ModuleDefinition] = {
|
||||
# Dev-Tools module - imported from app/modules/dev_tools/
|
||||
"dev-tools": dev_tools_module,
|
||||
# Monitoring module - imported from app/modules/monitoring/
|
||||
@@ -114,6 +146,17 @@ MODULES: dict[str, ModuleDefinition] = {
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Combined Module Registry
|
||||
# =============================================================================
|
||||
|
||||
MODULES: dict[str, ModuleDefinition] = {
|
||||
**CORE_MODULES,
|
||||
**OPTIONAL_MODULES,
|
||||
**INTERNAL_MODULES,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper Functions
|
||||
# =============================================================================
|
||||
@@ -126,17 +169,32 @@ def get_module(code: str) -> ModuleDefinition | None:
|
||||
|
||||
def get_core_modules() -> list[ModuleDefinition]:
|
||||
"""Get all core modules (cannot be disabled)."""
|
||||
return [m for m in MODULES.values() if m.is_core]
|
||||
return list(CORE_MODULES.values())
|
||||
|
||||
|
||||
def get_core_module_codes() -> set[str]:
|
||||
"""Get codes of all core modules."""
|
||||
return {m.code for m in MODULES.values() if m.is_core}
|
||||
return set(CORE_MODULES.keys())
|
||||
|
||||
|
||||
def get_optional_modules() -> list[ModuleDefinition]:
|
||||
"""Get all optional modules (can be enabled/disabled)."""
|
||||
return [m for m in MODULES.values() if not m.is_core]
|
||||
return list(OPTIONAL_MODULES.values())
|
||||
|
||||
|
||||
def get_optional_module_codes() -> set[str]:
|
||||
"""Get codes of all optional modules."""
|
||||
return set(OPTIONAL_MODULES.keys())
|
||||
|
||||
|
||||
def get_internal_modules() -> list[ModuleDefinition]:
|
||||
"""Get all internal modules (admin-only tools)."""
|
||||
return list(INTERNAL_MODULES.values())
|
||||
|
||||
|
||||
def get_internal_module_codes() -> set[str]:
|
||||
"""Get codes of all internal modules."""
|
||||
return set(INTERNAL_MODULES.keys())
|
||||
|
||||
|
||||
def get_all_module_codes() -> set[str]:
|
||||
@@ -144,6 +202,16 @@ def get_all_module_codes() -> set[str]:
|
||||
return set(MODULES.keys())
|
||||
|
||||
|
||||
def is_core_module(code: str) -> bool:
|
||||
"""Check if a module is a core module."""
|
||||
return code in CORE_MODULES
|
||||
|
||||
|
||||
def is_internal_module(code: str) -> bool:
|
||||
"""Check if a module is an internal module."""
|
||||
return code in INTERNAL_MODULES
|
||||
|
||||
|
||||
def get_menu_item_module(menu_item_id: str, frontend_type: FrontendType) -> str | None:
|
||||
"""
|
||||
Find which module provides a specific menu item.
|
||||
@@ -202,6 +270,39 @@ def validate_module_dependencies() -> list[str]:
|
||||
return errors
|
||||
|
||||
|
||||
def get_modules_by_tier() -> dict[str, list[ModuleDefinition]]:
|
||||
"""
|
||||
Get modules organized by tier.
|
||||
|
||||
Returns:
|
||||
Dict with keys 'core', 'optional', 'internal' mapping to module lists
|
||||
"""
|
||||
return {
|
||||
"core": list(CORE_MODULES.values()),
|
||||
"optional": list(OPTIONAL_MODULES.values()),
|
||||
"internal": list(INTERNAL_MODULES.values()),
|
||||
}
|
||||
|
||||
|
||||
def get_module_tier(code: str) -> str | None:
|
||||
"""
|
||||
Get the tier classification of a module.
|
||||
|
||||
Args:
|
||||
code: Module code
|
||||
|
||||
Returns:
|
||||
'core', 'optional', 'internal', or None if not found
|
||||
"""
|
||||
if code in CORE_MODULES:
|
||||
return "core"
|
||||
elif code in OPTIONAL_MODULES:
|
||||
return "optional"
|
||||
elif code in INTERNAL_MODULES:
|
||||
return "internal"
|
||||
return None
|
||||
|
||||
|
||||
# Validate dependencies on import (development check)
|
||||
_validation_errors = validate_module_dependencies()
|
||||
if _validation_errors:
|
||||
@@ -212,13 +313,28 @@ if _validation_errors:
|
||||
|
||||
|
||||
__all__ = [
|
||||
# Module dictionaries
|
||||
"MODULES",
|
||||
"CORE_MODULES",
|
||||
"OPTIONAL_MODULES",
|
||||
"INTERNAL_MODULES",
|
||||
# Module retrieval
|
||||
"get_module",
|
||||
"get_core_modules",
|
||||
"get_core_module_codes",
|
||||
"get_optional_modules",
|
||||
"get_optional_module_codes",
|
||||
"get_internal_modules",
|
||||
"get_internal_module_codes",
|
||||
"get_all_module_codes",
|
||||
# Module classification
|
||||
"is_core_module",
|
||||
"is_internal_module",
|
||||
"get_modules_by_tier",
|
||||
"get_module_tier",
|
||||
# Menu and feature lookup
|
||||
"get_menu_item_module",
|
||||
"get_feature_module",
|
||||
# Validation
|
||||
"validate_module_dependencies",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user