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