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:
@@ -4,6 +4,16 @@ Modular Platform Architecture.
|
||||
|
||||
This package provides a module system for enabling/disabling feature bundles per platform.
|
||||
|
||||
Three-Tier Classification:
|
||||
1. CORE MODULES (4) - Always enabled, cannot be disabled
|
||||
- core, tenancy, cms, customers
|
||||
|
||||
2. OPTIONAL MODULES (7) - Can be enabled/disabled per platform
|
||||
- payments, billing, inventory, orders, marketplace, analytics, messaging
|
||||
|
||||
3. INTERNAL MODULES (2) - Admin-only tools, not customer-facing
|
||||
- dev-tools, monitoring
|
||||
|
||||
Module Hierarchy:
|
||||
Global (SaaS Provider)
|
||||
└── Platform (Business Product - OMS, Loyalty, etc.)
|
||||
@@ -11,7 +21,8 @@ Module Hierarchy:
|
||||
├── Routes (API + Page routes)
|
||||
├── Services (Business logic)
|
||||
├── Menu Items (Sidebar entries)
|
||||
└── Templates (UI components)
|
||||
├── Templates (UI components)
|
||||
└── Migrations (Module-specific)
|
||||
|
||||
Modules vs Features:
|
||||
- Features: Granular capabilities (e.g., analytics_dashboard, letzshop_sync)
|
||||
@@ -22,7 +33,8 @@ Modules vs Features:
|
||||
Usage:
|
||||
from app.modules import module_service
|
||||
from app.modules.base import ModuleDefinition
|
||||
from app.modules.registry import MODULES
|
||||
from app.modules.registry import MODULES, CORE_MODULES, OPTIONAL_MODULES
|
||||
from app.modules.events import module_event_bus, ModuleEvent
|
||||
|
||||
# Check if module is enabled for platform
|
||||
if module_service.is_module_enabled(platform_id, "billing"):
|
||||
@@ -33,15 +45,55 @@ Usage:
|
||||
|
||||
# Get all enabled modules for platform
|
||||
modules = module_service.get_platform_modules(platform_id)
|
||||
|
||||
# Subscribe to module events
|
||||
@module_event_bus.subscribe(ModuleEvent.ENABLED)
|
||||
def on_enabled(data):
|
||||
print(f"Module {data.module_code} enabled")
|
||||
"""
|
||||
|
||||
from app.modules.base import ModuleDefinition
|
||||
from app.modules.registry import MODULES
|
||||
from app.modules.registry import (
|
||||
MODULES,
|
||||
CORE_MODULES,
|
||||
OPTIONAL_MODULES,
|
||||
INTERNAL_MODULES,
|
||||
get_core_module_codes,
|
||||
get_optional_module_codes,
|
||||
get_internal_module_codes,
|
||||
get_module_tier,
|
||||
is_core_module,
|
||||
is_internal_module,
|
||||
)
|
||||
from app.modules.service import ModuleService, module_service
|
||||
from app.modules.events import (
|
||||
ModuleEvent,
|
||||
ModuleEventData,
|
||||
ModuleEventBus,
|
||||
module_event_bus,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Core types
|
||||
"ModuleDefinition",
|
||||
# Module dictionaries
|
||||
"MODULES",
|
||||
"CORE_MODULES",
|
||||
"OPTIONAL_MODULES",
|
||||
"INTERNAL_MODULES",
|
||||
# Helper functions
|
||||
"get_core_module_codes",
|
||||
"get_optional_module_codes",
|
||||
"get_internal_module_codes",
|
||||
"get_module_tier",
|
||||
"is_core_module",
|
||||
"is_internal_module",
|
||||
# Service
|
||||
"ModuleService",
|
||||
"module_service",
|
||||
# Events
|
||||
"ModuleEvent",
|
||||
"ModuleEventData",
|
||||
"ModuleEventBus",
|
||||
"module_event_bus",
|
||||
]
|
||||
|
||||
@@ -11,6 +11,12 @@ per platform. Each module contains:
|
||||
- Models: Database models (optional, for self-contained modules)
|
||||
- Schemas: Pydantic schemas (optional, for self-contained modules)
|
||||
- Templates: Jinja2 templates (optional, for self-contained modules)
|
||||
- Migrations: Database migrations (optional, for self-contained modules)
|
||||
|
||||
Module Classification:
|
||||
- Core modules: Always enabled, cannot be disabled (core, tenancy, cms, customers)
|
||||
- Optional modules: Can be enabled/disabled per platform
|
||||
- Internal modules: Admin-only tools, not customer-facing (dev-tools, monitoring)
|
||||
|
||||
Self-Contained Module Structure:
|
||||
app/modules/<code>/
|
||||
@@ -22,15 +28,19 @@ Self-Contained Module Structure:
|
||||
├── services/ # Business logic
|
||||
├── models/ # SQLAlchemy models
|
||||
├── schemas/ # Pydantic schemas
|
||||
└── templates/ # Jinja2 templates (namespaced)
|
||||
├── migrations/ # Alembic migrations for this module
|
||||
│ └── versions/ # Migration scripts
|
||||
├── templates/ # Jinja2 templates (namespaced)
|
||||
└── locales/ # Translation files
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from models.database.admin_menu_config import FrontendType
|
||||
|
||||
@@ -43,26 +53,51 @@ class ModuleDefinition:
|
||||
A module groups related functionality that can be enabled/disabled per platform.
|
||||
Core modules cannot be disabled and are always available.
|
||||
|
||||
Self-contained modules include their own services, models, schemas, and templates.
|
||||
The path attributes describe where these components are located.
|
||||
Self-contained modules include their own services, models, schemas, templates,
|
||||
and migrations. The path attributes describe where these components are located.
|
||||
|
||||
Attributes:
|
||||
# Identity
|
||||
code: Unique identifier (e.g., "billing", "marketplace")
|
||||
name: Display name (e.g., "Billing & Subscriptions")
|
||||
description: Description of what this module provides
|
||||
version: Semantic version of the module (e.g., "1.0.0")
|
||||
|
||||
# Dependencies
|
||||
requires: List of module codes this module depends on
|
||||
|
||||
# Components
|
||||
features: List of feature codes this module provides
|
||||
menu_items: Dict mapping FrontendType to list of menu item IDs
|
||||
permissions: List of permission codes this module defines
|
||||
|
||||
# Classification
|
||||
is_core: Core modules cannot be disabled
|
||||
is_internal: Internal modules are admin-only (not customer-facing)
|
||||
|
||||
# Configuration
|
||||
config_schema: Pydantic model class for module configuration
|
||||
default_config: Default configuration values
|
||||
|
||||
# Routes
|
||||
admin_router: FastAPI router for admin routes
|
||||
vendor_router: FastAPI router for vendor routes
|
||||
services_path: Path to services subpackage (e.g., "app.modules.billing.services")
|
||||
models_path: Path to models subpackage (e.g., "app.modules.billing.models")
|
||||
schemas_path: Path to schemas subpackage (e.g., "app.modules.billing.schemas")
|
||||
templates_path: Path to templates directory (relative to module)
|
||||
exceptions_path: Path to exceptions module (e.g., "app.modules.billing.exceptions")
|
||||
locales_path: Path to locales directory (relative to module, e.g., "locales")
|
||||
|
||||
# Lifecycle hooks
|
||||
on_enable: Called when module is enabled for a platform
|
||||
on_disable: Called when module is disabled for a platform
|
||||
on_startup: Called when application starts (for enabled modules)
|
||||
health_check: Called to check module health status
|
||||
|
||||
# Self-contained module paths (optional)
|
||||
is_self_contained: Whether module uses self-contained structure
|
||||
services_path: Path to services subpackage
|
||||
models_path: Path to models subpackage
|
||||
schemas_path: Path to schemas subpackage
|
||||
templates_path: Path to templates directory (relative to module)
|
||||
exceptions_path: Path to exceptions module
|
||||
locales_path: Path to locales directory (relative to module)
|
||||
migrations_path: Path to migrations directory (relative to module)
|
||||
|
||||
Example (traditional thin wrapper):
|
||||
billing_module = ModuleDefinition(
|
||||
@@ -76,53 +111,89 @@ class ModuleDefinition:
|
||||
},
|
||||
)
|
||||
|
||||
Example (self-contained module):
|
||||
Example (self-contained module with configuration):
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class CMSConfig(BaseModel):
|
||||
max_pages: int = Field(default=100, ge=1)
|
||||
enable_seo: bool = True
|
||||
|
||||
cms_module = ModuleDefinition(
|
||||
code="cms",
|
||||
name="Content Management",
|
||||
description="Content pages, media library, and vendor themes.",
|
||||
version="1.0.0",
|
||||
features=["cms_basic", "cms_custom_pages"],
|
||||
menu_items={
|
||||
FrontendType.ADMIN: ["content-pages"],
|
||||
FrontendType.VENDOR: ["content-pages", "media"],
|
||||
},
|
||||
config_schema=CMSConfig,
|
||||
default_config={"max_pages": 100, "enable_seo": True},
|
||||
is_self_contained=True,
|
||||
services_path="app.modules.cms.services",
|
||||
models_path="app.modules.cms.models",
|
||||
schemas_path="app.modules.cms.schemas",
|
||||
templates_path="templates",
|
||||
exceptions_path="app.modules.cms.exceptions",
|
||||
locales_path="locales",
|
||||
migrations_path="migrations",
|
||||
health_check=lambda: {"status": "healthy"},
|
||||
)
|
||||
"""
|
||||
|
||||
# =========================================================================
|
||||
# Identity
|
||||
# =========================================================================
|
||||
code: str
|
||||
name: str
|
||||
description: str = ""
|
||||
version: str = "1.0.0"
|
||||
|
||||
# =========================================================================
|
||||
# Dependencies
|
||||
# =========================================================================
|
||||
requires: list[str] = field(default_factory=list)
|
||||
|
||||
# =========================================================================
|
||||
# Components
|
||||
# =========================================================================
|
||||
features: list[str] = field(default_factory=list)
|
||||
menu_items: dict[FrontendType, list[str]] = field(default_factory=dict)
|
||||
permissions: list[str] = field(default_factory=list)
|
||||
|
||||
# Status
|
||||
# =========================================================================
|
||||
# Classification
|
||||
# =========================================================================
|
||||
is_core: bool = False
|
||||
is_internal: bool = False
|
||||
|
||||
# =========================================================================
|
||||
# Configuration
|
||||
# =========================================================================
|
||||
config_schema: "type[BaseModel] | None" = None
|
||||
default_config: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# =========================================================================
|
||||
# Routes (registered dynamically)
|
||||
# =========================================================================
|
||||
admin_router: "APIRouter | None" = None
|
||||
vendor_router: "APIRouter | None" = None
|
||||
|
||||
# Self-contained module paths (optional)
|
||||
# =========================================================================
|
||||
# Lifecycle Hooks
|
||||
# =========================================================================
|
||||
on_enable: Callable[[int], None] | None = None # Called with platform_id
|
||||
on_disable: Callable[[int], None] | None = None # Called with platform_id
|
||||
on_startup: Callable[[], None] | None = None # Called on app startup
|
||||
health_check: Callable[[], dict[str, Any]] | None = None # Returns health status
|
||||
|
||||
# =========================================================================
|
||||
# Self-Contained Module Paths (optional)
|
||||
# =========================================================================
|
||||
is_self_contained: bool = False
|
||||
services_path: str | None = None
|
||||
models_path: str | None = None
|
||||
schemas_path: str | None = None
|
||||
templates_path: str | None = None # Relative to module directory
|
||||
exceptions_path: str | None = None
|
||||
locales_path: str | None = None # Relative to module directory, e.g., "locales"
|
||||
locales_path: str | None = None # Relative to module directory
|
||||
migrations_path: str | None = None # Relative to module directory, e.g., "migrations"
|
||||
|
||||
# =========================================================================
|
||||
# Menu Item Methods
|
||||
# =========================================================================
|
||||
|
||||
def get_menu_items(self, frontend_type: FrontendType) -> list[str]:
|
||||
"""Get menu item IDs for a specific frontend type."""
|
||||
@@ -135,13 +206,25 @@ class ModuleDefinition:
|
||||
all_items.update(items)
|
||||
return all_items
|
||||
|
||||
def has_menu_item(self, menu_item_id: str) -> bool:
|
||||
"""Check if this module provides a specific menu item."""
|
||||
return menu_item_id in self.get_all_menu_items()
|
||||
|
||||
# =========================================================================
|
||||
# Feature Methods
|
||||
# =========================================================================
|
||||
|
||||
def has_feature(self, feature_code: str) -> bool:
|
||||
"""Check if this module provides a specific feature."""
|
||||
return feature_code in self.features
|
||||
|
||||
def has_menu_item(self, menu_item_id: str) -> bool:
|
||||
"""Check if this module provides a specific menu item."""
|
||||
return menu_item_id in self.get_all_menu_items()
|
||||
def has_permission(self, permission_code: str) -> bool:
|
||||
"""Check if this module defines a specific permission."""
|
||||
return permission_code in self.permissions
|
||||
|
||||
# =========================================================================
|
||||
# Dependency Methods
|
||||
# =========================================================================
|
||||
|
||||
def check_dependencies(self, enabled_modules: set[str]) -> list[str]:
|
||||
"""
|
||||
@@ -155,6 +238,70 @@ class ModuleDefinition:
|
||||
"""
|
||||
return [req for req in self.requires if req not in enabled_modules]
|
||||
|
||||
# =========================================================================
|
||||
# Configuration Methods
|
||||
# =========================================================================
|
||||
|
||||
def validate_config(self, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Validate configuration against the schema.
|
||||
|
||||
Args:
|
||||
config: Configuration dict to validate
|
||||
|
||||
Returns:
|
||||
Validated configuration dict
|
||||
|
||||
Raises:
|
||||
ValidationError: If configuration is invalid
|
||||
"""
|
||||
if self.config_schema is None:
|
||||
return config
|
||||
|
||||
# Merge with defaults
|
||||
merged = {**self.default_config, **config}
|
||||
# Validate using Pydantic model
|
||||
validated = self.config_schema(**merged)
|
||||
return validated.model_dump()
|
||||
|
||||
def get_default_config(self) -> dict[str, Any]:
|
||||
"""Get the default configuration for this module."""
|
||||
if self.config_schema is None:
|
||||
return self.default_config.copy()
|
||||
|
||||
# Use Pydantic model defaults
|
||||
return self.config_schema().model_dump()
|
||||
|
||||
# =========================================================================
|
||||
# Lifecycle Methods
|
||||
# =========================================================================
|
||||
|
||||
def run_on_enable(self, platform_id: int) -> None:
|
||||
"""Run the on_enable hook if defined."""
|
||||
if self.on_enable:
|
||||
self.on_enable(platform_id)
|
||||
|
||||
def run_on_disable(self, platform_id: int) -> None:
|
||||
"""Run the on_disable hook if defined."""
|
||||
if self.on_disable:
|
||||
self.on_disable(platform_id)
|
||||
|
||||
def run_on_startup(self) -> None:
|
||||
"""Run the on_startup hook if defined."""
|
||||
if self.on_startup:
|
||||
self.on_startup()
|
||||
|
||||
def get_health_status(self) -> dict[str, Any]:
|
||||
"""
|
||||
Get the health status of this module.
|
||||
|
||||
Returns:
|
||||
Dict with at least a 'status' key ('healthy', 'degraded', 'unhealthy')
|
||||
"""
|
||||
if self.health_check:
|
||||
return self.health_check()
|
||||
return {"status": "healthy", "message": "No health check defined"}
|
||||
|
||||
# =========================================================================
|
||||
# Self-Contained Module Methods
|
||||
# =========================================================================
|
||||
@@ -166,7 +313,9 @@ class ModuleDefinition:
|
||||
Returns:
|
||||
Path to app/modules/<code>/
|
||||
"""
|
||||
return Path(__file__).parent / self.code
|
||||
# Handle module codes with hyphens (e.g., dev-tools -> dev_tools)
|
||||
dir_name = self.code.replace("-", "_")
|
||||
return Path(__file__).parent / dir_name
|
||||
|
||||
def get_templates_dir(self) -> Path | None:
|
||||
"""
|
||||
@@ -190,6 +339,17 @@ class ModuleDefinition:
|
||||
return None
|
||||
return self.get_module_dir() / self.locales_path
|
||||
|
||||
def get_migrations_dir(self) -> Path | None:
|
||||
"""
|
||||
Get the filesystem path to this module's migrations directory.
|
||||
|
||||
Returns:
|
||||
Path to migrations directory, or None if not configured
|
||||
"""
|
||||
if not self.migrations_path:
|
||||
return None
|
||||
return self.get_module_dir() / self.migrations_path
|
||||
|
||||
def get_import_path(self, component: str) -> str | None:
|
||||
"""
|
||||
Get the Python import path for a module component.
|
||||
@@ -237,6 +397,8 @@ class ModuleDefinition:
|
||||
expected_dirs.append(self.templates_path)
|
||||
if self.locales_path:
|
||||
expected_dirs.append(self.locales_path)
|
||||
if self.migrations_path:
|
||||
expected_dirs.append(self.migrations_path)
|
||||
|
||||
for dir_name in expected_dirs:
|
||||
dir_path = module_dir / dir_name
|
||||
@@ -245,6 +407,28 @@ class ModuleDefinition:
|
||||
|
||||
return issues
|
||||
|
||||
# =========================================================================
|
||||
# Classification Methods
|
||||
# =========================================================================
|
||||
|
||||
def get_tier(self) -> str:
|
||||
"""
|
||||
Get the tier classification of this module.
|
||||
|
||||
Returns:
|
||||
'core', 'internal', or 'optional'
|
||||
"""
|
||||
if self.is_core:
|
||||
return "core"
|
||||
elif self.is_internal:
|
||||
return "internal"
|
||||
else:
|
||||
return "optional"
|
||||
|
||||
# =========================================================================
|
||||
# Magic Methods
|
||||
# =========================================================================
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.code)
|
||||
|
||||
@@ -254,5 +438,6 @@ class ModuleDefinition:
|
||||
return False
|
||||
|
||||
def __repr__(self) -> str:
|
||||
tier = self.get_tier()
|
||||
sc = ", self_contained" if self.is_self_contained else ""
|
||||
return f"<Module({self.code}, core={self.is_core}{sc})>"
|
||||
return f"<Module({self.code}, tier={tier}{sc})>"
|
||||
|
||||
@@ -29,13 +29,14 @@ billing_module = ModuleDefinition(
|
||||
code="billing",
|
||||
name="Billing & Subscriptions",
|
||||
description=(
|
||||
"Subscription tier management, vendor billing, payment processing, "
|
||||
"and invoice history. Integrates with Stripe for payment collection."
|
||||
"Platform subscription management, vendor billing, and invoice history. "
|
||||
"Uses the payments module for actual payment processing."
|
||||
),
|
||||
version="1.0.0",
|
||||
requires=["payments"], # Depends on payments module for payment processing
|
||||
features=[
|
||||
"subscription_management", # Manage subscription tiers
|
||||
"billing_history", # View invoices and payment history
|
||||
"stripe_integration", # Stripe payment processing
|
||||
"invoice_generation", # Generate and download invoices
|
||||
"subscription_analytics", # Subscription stats and metrics
|
||||
"trial_management", # Manage vendor trial periods
|
||||
|
||||
@@ -53,7 +53,7 @@ cms_module = ModuleDefinition(
|
||||
"media", # Media library
|
||||
],
|
||||
},
|
||||
is_core=False,
|
||||
is_core=True, # CMS is a core module - content management is fundamental
|
||||
# Self-contained module configuration
|
||||
is_self_contained=True,
|
||||
services_path="app.modules.cms.services",
|
||||
|
||||
@@ -43,7 +43,7 @@ customers_module = ModuleDefinition(
|
||||
"customers", # Vendor customer list
|
||||
],
|
||||
},
|
||||
is_core=False,
|
||||
is_core=True, # Customers is a core module - customer data is fundamental
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ dev_tools_module = ModuleDefinition(
|
||||
code="dev-tools",
|
||||
name="Developer Tools",
|
||||
description="Component library and icon browser for development.",
|
||||
version="1.0.0",
|
||||
features=[
|
||||
"component_library", # UI component browser
|
||||
"icon_browser", # Icon library browser
|
||||
@@ -29,6 +30,7 @@ dev_tools_module = ModuleDefinition(
|
||||
FrontendType.VENDOR: [], # No vendor menu items
|
||||
},
|
||||
is_core=False,
|
||||
is_internal=True, # Internal module - admin-only, not customer-facing
|
||||
)
|
||||
|
||||
|
||||
|
||||
325
app/modules/events.py
Normal file
325
app/modules/events.py
Normal file
@@ -0,0 +1,325 @@
|
||||
# app/modules/events.py
|
||||
"""
|
||||
Module event bus for lifecycle events.
|
||||
|
||||
Provides a way for different parts of the application to subscribe to
|
||||
module lifecycle events (enable, disable, startup, shutdown, config changes).
|
||||
|
||||
Usage:
|
||||
from app.modules.events import module_event_bus, ModuleEvent
|
||||
|
||||
# Subscribe to events
|
||||
@module_event_bus.subscribe(ModuleEvent.ENABLED)
|
||||
def on_module_enabled(event_data: ModuleEventData):
|
||||
print(f"Module {event_data.module_code} enabled for platform {event_data.platform_id}")
|
||||
|
||||
# Or subscribe manually
|
||||
module_event_bus.subscribe(ModuleEvent.DISABLED, my_handler)
|
||||
|
||||
# Emit events (typically done by ModuleService)
|
||||
module_event_bus.emit(ModuleEvent.ENABLED, ModuleEventData(
|
||||
module_code="billing",
|
||||
platform_id=1,
|
||||
user_id=42,
|
||||
))
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Any, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ModuleEvent(str, Enum):
|
||||
"""Module lifecycle events."""
|
||||
|
||||
# Module enablement
|
||||
ENABLED = "enabled" # Module enabled for a platform
|
||||
DISABLED = "disabled" # Module disabled for a platform
|
||||
|
||||
# Application lifecycle
|
||||
STARTUP = "startup" # Application starting, module initializing
|
||||
SHUTDOWN = "shutdown" # Application shutting down
|
||||
|
||||
# Configuration
|
||||
CONFIG_CHANGED = "config_changed" # Module configuration updated
|
||||
|
||||
# Health
|
||||
HEALTH_CHECK = "health_check" # Health check performed
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModuleEventData:
|
||||
"""
|
||||
Data passed with module events.
|
||||
|
||||
Attributes:
|
||||
module_code: The module code (e.g., "billing", "cms")
|
||||
platform_id: Platform ID if event is platform-specific
|
||||
user_id: User ID if action was user-initiated
|
||||
config: Configuration data (for CONFIG_CHANGED)
|
||||
metadata: Additional event-specific data
|
||||
timestamp: When the event occurred
|
||||
"""
|
||||
|
||||
module_code: str
|
||||
platform_id: int | None = None
|
||||
user_id: int | None = None
|
||||
config: dict[str, Any] | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
# Type alias for event handlers
|
||||
EventHandler = Callable[[ModuleEventData], None]
|
||||
|
||||
|
||||
class ModuleEventBus:
|
||||
"""
|
||||
Event bus for module lifecycle events.
|
||||
|
||||
Allows components to subscribe to and emit module events.
|
||||
This enables loose coupling between the module system and other
|
||||
parts of the application that need to react to module changes.
|
||||
|
||||
Example:
|
||||
# In a service that needs to react to module changes
|
||||
@module_event_bus.subscribe(ModuleEvent.ENABLED)
|
||||
def on_billing_enabled(data: ModuleEventData):
|
||||
if data.module_code == "billing":
|
||||
setup_stripe_webhook(data.platform_id)
|
||||
|
||||
# Or subscribe to multiple events
|
||||
@module_event_bus.subscribe(ModuleEvent.ENABLED, ModuleEvent.DISABLED)
|
||||
def on_module_change(data: ModuleEventData):
|
||||
clear_menu_cache(data.platform_id)
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._handlers: dict[ModuleEvent, list[EventHandler]] = {
|
||||
event: [] for event in ModuleEvent
|
||||
}
|
||||
self._global_handlers: list[EventHandler] = []
|
||||
|
||||
def subscribe(
|
||||
self,
|
||||
*events: ModuleEvent,
|
||||
) -> Callable[[EventHandler], EventHandler]:
|
||||
"""
|
||||
Decorator to subscribe a handler to one or more events.
|
||||
|
||||
Args:
|
||||
events: Events to subscribe to. If empty, subscribes to all events.
|
||||
|
||||
Returns:
|
||||
Decorator function
|
||||
|
||||
Example:
|
||||
@module_event_bus.subscribe(ModuleEvent.ENABLED)
|
||||
def on_enabled(data: ModuleEventData):
|
||||
print(f"Module {data.module_code} enabled")
|
||||
"""
|
||||
|
||||
def decorator(handler: EventHandler) -> EventHandler:
|
||||
if not events:
|
||||
# Subscribe to all events
|
||||
self._global_handlers.append(handler)
|
||||
else:
|
||||
for event in events:
|
||||
self._handlers[event].append(handler)
|
||||
return handler
|
||||
|
||||
return decorator
|
||||
|
||||
def subscribe_handler(
|
||||
self,
|
||||
event: ModuleEvent,
|
||||
handler: EventHandler,
|
||||
) -> None:
|
||||
"""
|
||||
Subscribe a handler to a specific event.
|
||||
|
||||
Args:
|
||||
event: Event to subscribe to
|
||||
handler: Handler function to call
|
||||
"""
|
||||
self._handlers[event].append(handler)
|
||||
|
||||
def unsubscribe(
|
||||
self,
|
||||
event: ModuleEvent,
|
||||
handler: EventHandler,
|
||||
) -> bool:
|
||||
"""
|
||||
Unsubscribe a handler from an event.
|
||||
|
||||
Args:
|
||||
event: Event to unsubscribe from
|
||||
handler: Handler function to remove
|
||||
|
||||
Returns:
|
||||
True if handler was found and removed
|
||||
"""
|
||||
try:
|
||||
self._handlers[event].remove(handler)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def emit(
|
||||
self,
|
||||
event: ModuleEvent,
|
||||
data: ModuleEventData,
|
||||
) -> None:
|
||||
"""
|
||||
Emit an event to all subscribed handlers.
|
||||
|
||||
Args:
|
||||
event: Event type to emit
|
||||
data: Event data to pass to handlers
|
||||
|
||||
Note:
|
||||
Handlers are called synchronously in registration order.
|
||||
Exceptions in handlers are logged but don't prevent other
|
||||
handlers from being called.
|
||||
"""
|
||||
logger.debug(
|
||||
f"Emitting {event.value} event for module {data.module_code}"
|
||||
+ (f" on platform {data.platform_id}" if data.platform_id else "")
|
||||
)
|
||||
|
||||
# Call event-specific handlers
|
||||
for handler in self._handlers[event]:
|
||||
try:
|
||||
handler(data)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Error in {event.value} handler {handler.__name__}: {e}"
|
||||
)
|
||||
|
||||
# Call global handlers
|
||||
for handler in self._global_handlers:
|
||||
try:
|
||||
handler(data)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Error in global handler {handler.__name__}: {e}"
|
||||
)
|
||||
|
||||
def emit_enabled(
|
||||
self,
|
||||
module_code: str,
|
||||
platform_id: int,
|
||||
user_id: int | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Convenience method to emit an ENABLED event.
|
||||
|
||||
Args:
|
||||
module_code: Module that was enabled
|
||||
platform_id: Platform it was enabled for
|
||||
user_id: User who enabled it
|
||||
"""
|
||||
self.emit(
|
||||
ModuleEvent.ENABLED,
|
||||
ModuleEventData(
|
||||
module_code=module_code,
|
||||
platform_id=platform_id,
|
||||
user_id=user_id,
|
||||
),
|
||||
)
|
||||
|
||||
def emit_disabled(
|
||||
self,
|
||||
module_code: str,
|
||||
platform_id: int,
|
||||
user_id: int | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Convenience method to emit a DISABLED event.
|
||||
|
||||
Args:
|
||||
module_code: Module that was disabled
|
||||
platform_id: Platform it was disabled for
|
||||
user_id: User who disabled it
|
||||
"""
|
||||
self.emit(
|
||||
ModuleEvent.DISABLED,
|
||||
ModuleEventData(
|
||||
module_code=module_code,
|
||||
platform_id=platform_id,
|
||||
user_id=user_id,
|
||||
),
|
||||
)
|
||||
|
||||
def emit_startup(self, module_code: str) -> None:
|
||||
"""
|
||||
Convenience method to emit a STARTUP event.
|
||||
|
||||
Args:
|
||||
module_code: Module that is starting up
|
||||
"""
|
||||
self.emit(
|
||||
ModuleEvent.STARTUP,
|
||||
ModuleEventData(module_code=module_code),
|
||||
)
|
||||
|
||||
def emit_shutdown(self, module_code: str) -> None:
|
||||
"""
|
||||
Convenience method to emit a SHUTDOWN event.
|
||||
|
||||
Args:
|
||||
module_code: Module that is shutting down
|
||||
"""
|
||||
self.emit(
|
||||
ModuleEvent.SHUTDOWN,
|
||||
ModuleEventData(module_code=module_code),
|
||||
)
|
||||
|
||||
def emit_config_changed(
|
||||
self,
|
||||
module_code: str,
|
||||
platform_id: int,
|
||||
config: dict[str, Any],
|
||||
user_id: int | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Convenience method to emit a CONFIG_CHANGED event.
|
||||
|
||||
Args:
|
||||
module_code: Module whose config changed
|
||||
platform_id: Platform the config belongs to
|
||||
config: New configuration values
|
||||
user_id: User who changed the config
|
||||
"""
|
||||
self.emit(
|
||||
ModuleEvent.CONFIG_CHANGED,
|
||||
ModuleEventData(
|
||||
module_code=module_code,
|
||||
platform_id=platform_id,
|
||||
config=config,
|
||||
user_id=user_id,
|
||||
),
|
||||
)
|
||||
|
||||
def clear_handlers(self) -> None:
|
||||
"""Clear all handlers. Useful for testing."""
|
||||
for event in ModuleEvent:
|
||||
self._handlers[event] = []
|
||||
self._global_handlers = []
|
||||
|
||||
|
||||
# Global event bus instance
|
||||
module_event_bus = ModuleEventBus()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ModuleEvent",
|
||||
"ModuleEventData",
|
||||
"ModuleEventBus",
|
||||
"EventHandler",
|
||||
"module_event_bus",
|
||||
]
|
||||
253
app/modules/migrations.py
Normal file
253
app/modules/migrations.py
Normal file
@@ -0,0 +1,253 @@
|
||||
# app/modules/migrations.py
|
||||
"""
|
||||
Module migration discovery utility.
|
||||
|
||||
Provides utilities for discovering and managing module-specific migrations.
|
||||
Each self-contained module can have its own migrations directory that will
|
||||
be included in Alembic's version locations.
|
||||
|
||||
Module Migration Structure:
|
||||
app/modules/<code>/
|
||||
└── migrations/
|
||||
└── versions/
|
||||
├── <module>_001_initial.py
|
||||
├── <module>_002_add_field.py
|
||||
└── ...
|
||||
|
||||
Migration Naming Convention:
|
||||
{module_code}_{sequence}_{description}.py
|
||||
Example: cms_001_create_content_pages.py
|
||||
|
||||
This ensures no collision between modules and makes it clear which
|
||||
module owns each migration.
|
||||
|
||||
Usage:
|
||||
# Get all migration paths for Alembic
|
||||
from app.modules.migrations import get_all_migration_paths
|
||||
|
||||
paths = get_all_migration_paths()
|
||||
# Returns: [Path("alembic/versions"), Path("app/modules/cms/migrations/versions"), ...]
|
||||
|
||||
# In alembic/env.py
|
||||
context.configure(
|
||||
version_locations=[str(p) for p in get_all_migration_paths()],
|
||||
...
|
||||
)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.modules.base import ModuleDefinition
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_core_migrations_path() -> Path:
|
||||
"""
|
||||
Get the path to the core (non-module) migrations directory.
|
||||
|
||||
Returns:
|
||||
Path to alembic/versions/
|
||||
"""
|
||||
# Navigate from app/modules/migrations.py to project root
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
return project_root / "alembic" / "versions"
|
||||
|
||||
|
||||
def get_module_migrations_path(module: "ModuleDefinition") -> Path | None:
|
||||
"""
|
||||
Get the migrations path for a specific module.
|
||||
|
||||
Args:
|
||||
module: Module definition to get migrations path for
|
||||
|
||||
Returns:
|
||||
Path to module's migrations/versions/ directory, or None if not configured
|
||||
"""
|
||||
migrations_dir = module.get_migrations_dir()
|
||||
if migrations_dir is None:
|
||||
return None
|
||||
|
||||
versions_dir = migrations_dir / "versions"
|
||||
return versions_dir
|
||||
|
||||
|
||||
def discover_module_migrations() -> list[Path]:
|
||||
"""
|
||||
Discover all module migration directories.
|
||||
|
||||
Scans all registered modules for those with migrations_path configured
|
||||
and returns paths to their versions directories.
|
||||
|
||||
Returns:
|
||||
List of paths to module migration version directories
|
||||
"""
|
||||
# Import here to avoid circular imports
|
||||
from app.modules.registry import MODULES
|
||||
|
||||
paths: list[Path] = []
|
||||
|
||||
for module in MODULES.values():
|
||||
if not module.migrations_path:
|
||||
continue
|
||||
|
||||
versions_path = get_module_migrations_path(module)
|
||||
if versions_path is None:
|
||||
continue
|
||||
|
||||
if versions_path.exists():
|
||||
paths.append(versions_path)
|
||||
logger.debug(f"Found migrations for module {module.code}: {versions_path}")
|
||||
else:
|
||||
logger.debug(
|
||||
f"Module {module.code} has migrations_path but no versions directory"
|
||||
)
|
||||
|
||||
return sorted(paths) # Sort for deterministic ordering
|
||||
|
||||
|
||||
def get_all_migration_paths() -> list[Path]:
|
||||
"""
|
||||
Get all migration paths including core and module migrations.
|
||||
|
||||
Returns:
|
||||
List of paths starting with core migrations, followed by module migrations
|
||||
in alphabetical order by module code.
|
||||
|
||||
Example:
|
||||
[
|
||||
Path("alembic/versions"),
|
||||
Path("app/modules/billing/migrations/versions"),
|
||||
Path("app/modules/cms/migrations/versions"),
|
||||
]
|
||||
"""
|
||||
paths = [get_core_migrations_path()]
|
||||
paths.extend(discover_module_migrations())
|
||||
return paths
|
||||
|
||||
|
||||
def get_migration_order() -> list[str]:
|
||||
"""
|
||||
Get the order in which module migrations should be applied.
|
||||
|
||||
Returns migrations in dependency order - modules with no dependencies first,
|
||||
then modules that depend on them, etc.
|
||||
|
||||
Returns:
|
||||
List of module codes in migration order
|
||||
"""
|
||||
# Import here to avoid circular imports
|
||||
from app.modules.registry import MODULES
|
||||
|
||||
# Build dependency graph
|
||||
modules_with_migrations = [
|
||||
m for m in MODULES.values() if m.migrations_path
|
||||
]
|
||||
|
||||
if not modules_with_migrations:
|
||||
return []
|
||||
|
||||
# Topological sort based on dependencies
|
||||
ordered: list[str] = []
|
||||
visited: set[str] = set()
|
||||
temp_visited: set[str] = set()
|
||||
|
||||
def visit(code: str) -> None:
|
||||
if code in visited:
|
||||
return
|
||||
if code in temp_visited:
|
||||
raise ValueError(f"Circular dependency detected involving {code}")
|
||||
|
||||
module = MODULES.get(code)
|
||||
if module is None:
|
||||
return
|
||||
|
||||
temp_visited.add(code)
|
||||
|
||||
# Visit dependencies first
|
||||
for dep in module.requires:
|
||||
if dep in {m.code for m in modules_with_migrations}:
|
||||
visit(dep)
|
||||
|
||||
temp_visited.remove(code)
|
||||
visited.add(code)
|
||||
|
||||
if module.migrations_path:
|
||||
ordered.append(code)
|
||||
|
||||
for module in modules_with_migrations:
|
||||
visit(module.code)
|
||||
|
||||
return ordered
|
||||
|
||||
|
||||
def validate_migration_names() -> list[str]:
|
||||
"""
|
||||
Validate that all module migrations follow the naming convention.
|
||||
|
||||
Returns:
|
||||
List of validation errors (empty if all valid)
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
for path in discover_module_migrations():
|
||||
# Extract module code from path (e.g., app/modules/cms/migrations/versions -> cms)
|
||||
module_dir = path.parent.parent # migrations/versions -> migrations -> module
|
||||
module_code = module_dir.name.replace("_", "-") # cms or dev_tools -> dev-tools
|
||||
|
||||
for migration_file in path.glob("*.py"):
|
||||
name = migration_file.stem
|
||||
if name == "__pycache__":
|
||||
continue
|
||||
|
||||
# Check prefix matches module code
|
||||
expected_prefix = module_code.replace("-", "_")
|
||||
if not name.startswith(f"{expected_prefix}_"):
|
||||
errors.append(
|
||||
f"Migration {migration_file} should start with '{expected_prefix}_'"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def create_module_migrations_dir(module: "ModuleDefinition") -> Path:
|
||||
"""
|
||||
Create the migrations directory structure for a module.
|
||||
|
||||
Args:
|
||||
module: Module to create migrations for
|
||||
|
||||
Returns:
|
||||
Path to the created versions directory
|
||||
"""
|
||||
module_dir = module.get_module_dir()
|
||||
migrations_dir = module_dir / "migrations"
|
||||
versions_dir = migrations_dir / "versions"
|
||||
|
||||
versions_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create __init__.py files
|
||||
init_files = [
|
||||
migrations_dir / "__init__.py",
|
||||
versions_dir / "__init__.py",
|
||||
]
|
||||
for init_file in init_files:
|
||||
if not init_file.exists():
|
||||
init_file.write_text('"""Module migrations."""\n')
|
||||
|
||||
logger.info(f"Created migrations directory for module {module.code}")
|
||||
return versions_dir
|
||||
|
||||
|
||||
__all__ = [
|
||||
"get_core_migrations_path",
|
||||
"get_module_migrations_path",
|
||||
"discover_module_migrations",
|
||||
"get_all_migration_paths",
|
||||
"get_migration_order",
|
||||
"validate_migration_names",
|
||||
"create_module_migrations_dir",
|
||||
]
|
||||
@@ -21,7 +21,8 @@ def _get_admin_router():
|
||||
monitoring_module = ModuleDefinition(
|
||||
code="monitoring",
|
||||
name="Platform Monitoring",
|
||||
description="Logs, background tasks, imports, and system health.",
|
||||
description="Logs, background tasks, imports, system health, Flower, and Grafana integration.",
|
||||
version="1.0.0",
|
||||
features=[
|
||||
"application_logs", # Log viewing
|
||||
"background_tasks", # Task monitoring
|
||||
@@ -29,6 +30,8 @@ monitoring_module = ModuleDefinition(
|
||||
"capacity_monitoring", # System capacity
|
||||
"testing_hub", # Test runner
|
||||
"code_quality", # Code quality tools
|
||||
"flower_integration", # Celery Flower link
|
||||
"grafana_integration", # Grafana dashboard link
|
||||
],
|
||||
menu_items={
|
||||
FrontendType.ADMIN: [
|
||||
@@ -42,6 +45,7 @@ monitoring_module = ModuleDefinition(
|
||||
FrontendType.VENDOR: [], # No vendor menu items
|
||||
},
|
||||
is_core=False,
|
||||
is_internal=True, # Internal module - admin-only, not customer-facing
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -29,9 +29,11 @@ orders_module = ModuleDefinition(
|
||||
code="orders",
|
||||
name="Order Management",
|
||||
description=(
|
||||
"Order processing, fulfillment tracking, order item exceptions, "
|
||||
"and bulk order operations."
|
||||
"Order processing, fulfillment tracking, customer checkout, "
|
||||
"and bulk order operations. Uses the payments module for checkout."
|
||||
),
|
||||
version="1.0.0",
|
||||
requires=["payments"], # Depends on payments module for checkout
|
||||
features=[
|
||||
"order_management", # Basic order CRUD
|
||||
"order_bulk_actions", # Bulk status updates
|
||||
@@ -40,6 +42,7 @@ orders_module = ModuleDefinition(
|
||||
"fulfillment_tracking", # Shipping and tracking
|
||||
"shipping_management", # Carrier integration
|
||||
"order_exceptions", # Order item exception handling
|
||||
"customer_checkout", # Customer checkout flow
|
||||
],
|
||||
menu_items={
|
||||
FrontendType.ADMIN: [
|
||||
|
||||
32
app/modules/payments/__init__.py
Normal file
32
app/modules/payments/__init__.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# app/modules/payments/__init__.py
|
||||
"""
|
||||
Payments Module - Payment gateway integrations.
|
||||
|
||||
This module provides low-level payment gateway abstractions:
|
||||
- Gateway integrations (Stripe, PayPal, Bank Transfer, etc.)
|
||||
- Payment processing and refunds
|
||||
- Payment method storage and management
|
||||
- Transaction records
|
||||
|
||||
This module is used by:
|
||||
- billing: Platform subscriptions and vendor invoices
|
||||
- orders: Customer checkout and order payments
|
||||
|
||||
Routes:
|
||||
- Admin: /api/v1/admin/payments/*
|
||||
- Vendor: /api/v1/vendor/payments/*
|
||||
|
||||
Menu Items:
|
||||
- Admin: payments (payment configuration)
|
||||
- Vendor: payment-methods (stored payment methods)
|
||||
"""
|
||||
|
||||
from app.modules.payments.definition import payments_module
|
||||
|
||||
|
||||
def get_payments_module():
|
||||
"""Lazy getter to avoid circular imports."""
|
||||
return payments_module
|
||||
|
||||
|
||||
__all__ = ["payments_module", "get_payments_module"]
|
||||
79
app/modules/payments/definition.py
Normal file
79
app/modules/payments/definition.py
Normal file
@@ -0,0 +1,79 @@
|
||||
# app/modules/payments/definition.py
|
||||
"""
|
||||
Payments module definition.
|
||||
|
||||
Defines the payments module including its features, menu items,
|
||||
and route configurations.
|
||||
|
||||
The payments module provides gateway abstractions that can be used by:
|
||||
- billing module: For platform subscriptions and invoices
|
||||
- orders module: For customer checkout payments
|
||||
|
||||
This separation allows:
|
||||
1. Using payments standalone (e.g., one-time payments without subscriptions)
|
||||
2. Billing without orders (platform subscription only)
|
||||
3. Orders without billing (customer payments only)
|
||||
"""
|
||||
|
||||
from app.modules.base import ModuleDefinition
|
||||
from models.database.admin_menu_config import FrontendType
|
||||
|
||||
|
||||
def _get_admin_router():
|
||||
"""Lazy import of admin router to avoid circular imports."""
|
||||
from app.modules.payments.routes.admin import admin_router
|
||||
|
||||
return admin_router
|
||||
|
||||
|
||||
def _get_vendor_router():
|
||||
"""Lazy import of vendor router to avoid circular imports."""
|
||||
from app.modules.payments.routes.vendor import vendor_router
|
||||
|
||||
return vendor_router
|
||||
|
||||
|
||||
# Payments module definition
|
||||
payments_module = ModuleDefinition(
|
||||
code="payments",
|
||||
name="Payment Gateways",
|
||||
description=(
|
||||
"Payment gateway integrations for Stripe, PayPal, and bank transfers. "
|
||||
"Provides payment processing, refunds, and payment method management."
|
||||
),
|
||||
version="1.0.0",
|
||||
features=[
|
||||
"payment_processing", # Process payments
|
||||
"payment_refunds", # Issue refunds
|
||||
"payment_methods", # Store payment methods
|
||||
"stripe_gateway", # Stripe integration
|
||||
"paypal_gateway", # PayPal integration
|
||||
"bank_transfer", # Bank transfer support
|
||||
"transaction_history", # Transaction records
|
||||
],
|
||||
menu_items={
|
||||
FrontendType.ADMIN: [
|
||||
"payment-gateways", # Configure payment gateways
|
||||
],
|
||||
FrontendType.VENDOR: [
|
||||
"payment-methods", # Manage stored payment methods
|
||||
],
|
||||
},
|
||||
is_core=False,
|
||||
is_internal=False,
|
||||
)
|
||||
|
||||
|
||||
def get_payments_module_with_routers() -> ModuleDefinition:
|
||||
"""
|
||||
Get payments module with routers attached.
|
||||
|
||||
This function attaches the routers lazily to avoid circular imports
|
||||
during module initialization.
|
||||
"""
|
||||
payments_module.admin_router = _get_admin_router()
|
||||
payments_module.vendor_router = _get_vendor_router()
|
||||
return payments_module
|
||||
|
||||
|
||||
__all__ = ["payments_module", "get_payments_module_with_routers"]
|
||||
11
app/modules/payments/models/__init__.py
Normal file
11
app/modules/payments/models/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
# app/modules/payments/models/__init__.py
|
||||
"""
|
||||
Payments module database models.
|
||||
|
||||
Note: These models will be created in a future migration.
|
||||
For now, payment data may be stored in the billing module's tables.
|
||||
"""
|
||||
|
||||
# TODO: Add Payment, PaymentMethod, Transaction models
|
||||
|
||||
__all__: list[str] = []
|
||||
7
app/modules/payments/routes/__init__.py
Normal file
7
app/modules/payments/routes/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
# app/modules/payments/routes/__init__.py
|
||||
"""Payments module routes."""
|
||||
|
||||
from app.modules.payments.routes.admin import admin_router
|
||||
from app.modules.payments.routes.vendor import vendor_router
|
||||
|
||||
__all__ = ["admin_router", "vendor_router"]
|
||||
44
app/modules/payments/routes/admin.py
Normal file
44
app/modules/payments/routes/admin.py
Normal file
@@ -0,0 +1,44 @@
|
||||
# app/modules/payments/routes/admin.py
|
||||
"""
|
||||
Admin routes for payments module.
|
||||
|
||||
Provides routes for:
|
||||
- Payment gateway configuration
|
||||
- Transaction monitoring
|
||||
- Refund management
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
admin_router = APIRouter(prefix="/payments", tags=["Payments (Admin)"])
|
||||
|
||||
|
||||
@admin_router.get("/gateways")
|
||||
async def list_gateways():
|
||||
"""List configured payment gateways."""
|
||||
# TODO: Implement gateway listing
|
||||
return {
|
||||
"gateways": [
|
||||
{"code": "stripe", "name": "Stripe", "enabled": True},
|
||||
{"code": "paypal", "name": "PayPal", "enabled": False},
|
||||
{"code": "bank_transfer", "name": "Bank Transfer", "enabled": True},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@admin_router.get("/transactions")
|
||||
async def list_transactions():
|
||||
"""List recent transactions across all gateways."""
|
||||
# TODO: Implement transaction listing
|
||||
return {"transactions": [], "total": 0}
|
||||
|
||||
|
||||
@admin_router.post("/refunds/{transaction_id}")
|
||||
async def issue_refund(transaction_id: str, amount: float | None = None):
|
||||
"""Issue a refund for a transaction."""
|
||||
# TODO: Implement refund logic
|
||||
return {
|
||||
"status": "pending",
|
||||
"transaction_id": transaction_id,
|
||||
"refund_amount": amount,
|
||||
}
|
||||
40
app/modules/payments/routes/vendor.py
Normal file
40
app/modules/payments/routes/vendor.py
Normal file
@@ -0,0 +1,40 @@
|
||||
# app/modules/payments/routes/vendor.py
|
||||
"""
|
||||
Vendor routes for payments module.
|
||||
|
||||
Provides routes for:
|
||||
- Payment method management
|
||||
- Transaction history
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
vendor_router = APIRouter(prefix="/payments", tags=["Payments (Vendor)"])
|
||||
|
||||
|
||||
@vendor_router.get("/methods")
|
||||
async def list_payment_methods():
|
||||
"""List saved payment methods for the vendor."""
|
||||
# TODO: Implement payment method listing
|
||||
return {"payment_methods": []}
|
||||
|
||||
|
||||
@vendor_router.post("/methods")
|
||||
async def add_payment_method():
|
||||
"""Add a new payment method."""
|
||||
# TODO: Implement payment method creation
|
||||
return {"status": "created", "id": "pm_xxx"}
|
||||
|
||||
|
||||
@vendor_router.delete("/methods/{method_id}")
|
||||
async def remove_payment_method(method_id: str):
|
||||
"""Remove a saved payment method."""
|
||||
# TODO: Implement payment method deletion
|
||||
return {"status": "deleted", "id": method_id}
|
||||
|
||||
|
||||
@vendor_router.get("/transactions")
|
||||
async def list_vendor_transactions():
|
||||
"""List transactions for the vendor."""
|
||||
# TODO: Implement transaction listing
|
||||
return {"transactions": [], "total": 0}
|
||||
93
app/modules/payments/schemas/__init__.py
Normal file
93
app/modules/payments/schemas/__init__.py
Normal file
@@ -0,0 +1,93 @@
|
||||
# app/modules/payments/schemas/__init__.py
|
||||
"""
|
||||
Payments module Pydantic schemas.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
class PaymentRequest(BaseModel):
|
||||
"""Request to process a payment."""
|
||||
|
||||
amount: int = Field(..., gt=0, description="Amount in cents")
|
||||
currency: str = Field(default="EUR", max_length=3)
|
||||
payment_method_id: str | None = None
|
||||
gateway: str = Field(default="stripe")
|
||||
description: str | None = None
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PaymentResponse(BaseModel):
|
||||
"""Response from a payment operation."""
|
||||
|
||||
success: bool
|
||||
transaction_id: str | None = None
|
||||
gateway: str | None = None
|
||||
status: str
|
||||
amount: int
|
||||
currency: str
|
||||
error_message: str | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class RefundRequest(BaseModel):
|
||||
"""Request to issue a refund."""
|
||||
|
||||
transaction_id: str
|
||||
amount: int | None = Field(None, gt=0, description="Amount in cents, None for full refund")
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class RefundResponse(BaseModel):
|
||||
"""Response from a refund operation."""
|
||||
|
||||
success: bool
|
||||
refund_id: str | None = None
|
||||
transaction_id: str
|
||||
amount: int
|
||||
status: str
|
||||
error_message: str | None = None
|
||||
|
||||
|
||||
class PaymentMethodCreate(BaseModel):
|
||||
"""Request to create a payment method."""
|
||||
|
||||
gateway: str = "stripe"
|
||||
token: str # Gateway-specific token
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
class PaymentMethodResponse(BaseModel):
|
||||
"""Response for a payment method."""
|
||||
|
||||
id: str
|
||||
gateway: str
|
||||
type: str # card, bank_account, etc.
|
||||
last4: str | None = None
|
||||
is_default: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class GatewayResponse(BaseModel):
|
||||
"""Response for gateway info."""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
status: str
|
||||
enabled: bool
|
||||
supports_refunds: bool
|
||||
supports_recurring: bool
|
||||
supported_currencies: list[str]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PaymentRequest",
|
||||
"PaymentResponse",
|
||||
"RefundRequest",
|
||||
"RefundResponse",
|
||||
"PaymentMethodCreate",
|
||||
"PaymentMethodResponse",
|
||||
"GatewayResponse",
|
||||
]
|
||||
13
app/modules/payments/services/__init__.py
Normal file
13
app/modules/payments/services/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
# app/modules/payments/services/__init__.py
|
||||
"""
|
||||
Payments module services.
|
||||
|
||||
Provides:
|
||||
- PaymentService: Core payment processing
|
||||
- GatewayService: Gateway abstraction layer
|
||||
"""
|
||||
|
||||
from app.modules.payments.services.payment_service import PaymentService
|
||||
from app.modules.payments.services.gateway_service import GatewayService
|
||||
|
||||
__all__ = ["PaymentService", "GatewayService"]
|
||||
351
app/modules/payments/services/gateway_service.py
Normal file
351
app/modules/payments/services/gateway_service.py
Normal file
@@ -0,0 +1,351 @@
|
||||
# app/modules/payments/services/gateway_service.py
|
||||
"""
|
||||
Gateway service for managing payment gateway configurations.
|
||||
|
||||
This service handles:
|
||||
- Gateway configuration and credentials
|
||||
- Gateway health checks
|
||||
- Gateway-specific operations
|
||||
|
||||
Each gateway has its own implementation that conforms to the
|
||||
gateway protocol.
|
||||
|
||||
Usage:
|
||||
from app.modules.payments.services import GatewayService
|
||||
|
||||
gateway_service = GatewayService()
|
||||
|
||||
# Get available gateways
|
||||
gateways = gateway_service.get_available_gateways()
|
||||
|
||||
# Check gateway status
|
||||
status = await gateway_service.check_gateway_health("stripe")
|
||||
"""
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Protocol
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GatewayStatus(str, Enum):
|
||||
"""Gateway operational status."""
|
||||
|
||||
ACTIVE = "active"
|
||||
INACTIVE = "inactive"
|
||||
ERROR = "error"
|
||||
MAINTENANCE = "maintenance"
|
||||
|
||||
|
||||
@dataclass
|
||||
class GatewayInfo:
|
||||
"""Information about a payment gateway."""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
status: GatewayStatus
|
||||
enabled: bool
|
||||
supports_refunds: bool = True
|
||||
supports_recurring: bool = False
|
||||
supported_currencies: list[str] | None = None
|
||||
config: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class GatewayProtocol(Protocol):
|
||||
"""Protocol that all gateway implementations must follow."""
|
||||
|
||||
@property
|
||||
def code(self) -> str:
|
||||
"""Gateway code identifier."""
|
||||
...
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Gateway display name."""
|
||||
...
|
||||
|
||||
async def process_payment(
|
||||
self,
|
||||
amount: int,
|
||||
currency: str,
|
||||
payment_method: str,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Process a payment."""
|
||||
...
|
||||
|
||||
async def refund(
|
||||
self,
|
||||
transaction_id: str,
|
||||
amount: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Issue a refund."""
|
||||
...
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""Check if gateway is operational."""
|
||||
...
|
||||
|
||||
|
||||
class BaseGateway(ABC):
|
||||
"""Base class for gateway implementations."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def code(self) -> str:
|
||||
"""Gateway code identifier."""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Gateway display name."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def process_payment(
|
||||
self,
|
||||
amount: int,
|
||||
currency: str,
|
||||
payment_method: str,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Process a payment."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def refund(
|
||||
self,
|
||||
transaction_id: str,
|
||||
amount: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Issue a refund."""
|
||||
pass
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""Check if gateway is operational."""
|
||||
return True
|
||||
|
||||
|
||||
class StripeGateway(BaseGateway):
|
||||
"""Stripe payment gateway implementation."""
|
||||
|
||||
@property
|
||||
def code(self) -> str:
|
||||
return "stripe"
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "Stripe"
|
||||
|
||||
async def process_payment(
|
||||
self,
|
||||
amount: int,
|
||||
currency: str,
|
||||
payment_method: str,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Process a payment through Stripe."""
|
||||
# TODO: Implement Stripe payment processing
|
||||
logger.info(f"Processing Stripe payment: {amount} {currency}")
|
||||
return {
|
||||
"success": True,
|
||||
"transaction_id": f"pi_mock_{amount}",
|
||||
"gateway": self.code,
|
||||
}
|
||||
|
||||
async def refund(
|
||||
self,
|
||||
transaction_id: str,
|
||||
amount: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Issue a refund through Stripe."""
|
||||
# TODO: Implement Stripe refund
|
||||
logger.info(f"Processing Stripe refund for {transaction_id}")
|
||||
return {
|
||||
"success": True,
|
||||
"refund_id": f"re_mock_{transaction_id}",
|
||||
}
|
||||
|
||||
|
||||
class PayPalGateway(BaseGateway):
|
||||
"""PayPal payment gateway implementation."""
|
||||
|
||||
@property
|
||||
def code(self) -> str:
|
||||
return "paypal"
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "PayPal"
|
||||
|
||||
async def process_payment(
|
||||
self,
|
||||
amount: int,
|
||||
currency: str,
|
||||
payment_method: str,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Process a payment through PayPal."""
|
||||
# TODO: Implement PayPal payment processing
|
||||
logger.info(f"Processing PayPal payment: {amount} {currency}")
|
||||
return {
|
||||
"success": True,
|
||||
"transaction_id": f"paypal_mock_{amount}",
|
||||
"gateway": self.code,
|
||||
}
|
||||
|
||||
async def refund(
|
||||
self,
|
||||
transaction_id: str,
|
||||
amount: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Issue a refund through PayPal."""
|
||||
# TODO: Implement PayPal refund
|
||||
logger.info(f"Processing PayPal refund for {transaction_id}")
|
||||
return {
|
||||
"success": True,
|
||||
"refund_id": f"paypal_refund_{transaction_id}",
|
||||
}
|
||||
|
||||
|
||||
class BankTransferGateway(BaseGateway):
|
||||
"""Bank transfer gateway implementation."""
|
||||
|
||||
@property
|
||||
def code(self) -> str:
|
||||
return "bank_transfer"
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "Bank Transfer"
|
||||
|
||||
async def process_payment(
|
||||
self,
|
||||
amount: int,
|
||||
currency: str,
|
||||
payment_method: str,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Record a bank transfer payment (manual verification)."""
|
||||
logger.info(f"Recording bank transfer: {amount} {currency}")
|
||||
return {
|
||||
"success": True,
|
||||
"transaction_id": f"bt_mock_{amount}",
|
||||
"gateway": self.code,
|
||||
"status": "pending_verification",
|
||||
}
|
||||
|
||||
async def refund(
|
||||
self,
|
||||
transaction_id: str,
|
||||
amount: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Record a bank transfer refund (manual process)."""
|
||||
logger.info(f"Recording bank transfer refund for {transaction_id}")
|
||||
return {
|
||||
"success": True,
|
||||
"refund_id": f"bt_refund_{transaction_id}",
|
||||
"status": "pending_manual",
|
||||
}
|
||||
|
||||
|
||||
class GatewayService:
|
||||
"""
|
||||
Service for managing payment gateway configurations.
|
||||
|
||||
Provides a registry of available gateways and methods for
|
||||
gateway operations.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._gateways: dict[str, BaseGateway] = {
|
||||
"stripe": StripeGateway(),
|
||||
"paypal": PayPalGateway(),
|
||||
"bank_transfer": BankTransferGateway(),
|
||||
}
|
||||
self._enabled_gateways: set[str] = {"stripe", "bank_transfer"}
|
||||
|
||||
def get_gateway(self, code: str) -> BaseGateway | None:
|
||||
"""Get a gateway by code."""
|
||||
return self._gateways.get(code)
|
||||
|
||||
def get_available_gateways(self) -> list[GatewayInfo]:
|
||||
"""Get list of all available gateways with their status."""
|
||||
result = []
|
||||
for code, gateway in self._gateways.items():
|
||||
result.append(
|
||||
GatewayInfo(
|
||||
code=code,
|
||||
name=gateway.name,
|
||||
status=GatewayStatus.ACTIVE if code in self._enabled_gateways else GatewayStatus.INACTIVE,
|
||||
enabled=code in self._enabled_gateways,
|
||||
supports_refunds=True,
|
||||
supports_recurring=code == "stripe",
|
||||
supported_currencies=["EUR", "USD", "GBP"] if code != "bank_transfer" else ["EUR"],
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
def enable_gateway(self, code: str) -> bool:
|
||||
"""Enable a gateway."""
|
||||
if code in self._gateways:
|
||||
self._enabled_gateways.add(code)
|
||||
logger.info(f"Enabled gateway: {code}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def disable_gateway(self, code: str) -> bool:
|
||||
"""Disable a gateway."""
|
||||
if code in self._enabled_gateways:
|
||||
self._enabled_gateways.remove(code)
|
||||
logger.info(f"Disabled gateway: {code}")
|
||||
return True
|
||||
return False
|
||||
|
||||
async def check_gateway_health(self, code: str) -> dict[str, Any]:
|
||||
"""Check the health of a specific gateway."""
|
||||
gateway = self._gateways.get(code)
|
||||
if not gateway:
|
||||
return {"status": "unknown", "message": f"Gateway {code} not found"}
|
||||
|
||||
try:
|
||||
is_healthy = await gateway.health_check()
|
||||
return {
|
||||
"status": "healthy" if is_healthy else "unhealthy",
|
||||
"gateway": code,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception(f"Gateway health check failed: {code}")
|
||||
return {
|
||||
"status": "error",
|
||||
"gateway": code,
|
||||
"message": str(e),
|
||||
}
|
||||
|
||||
async def check_all_gateways(self) -> list[dict[str, Any]]:
|
||||
"""Check health of all enabled gateways."""
|
||||
results = []
|
||||
for code in self._enabled_gateways:
|
||||
result = await self.check_gateway_health(code)
|
||||
results.append(result)
|
||||
return results
|
||||
|
||||
|
||||
# Singleton instance
|
||||
gateway_service = GatewayService()
|
||||
|
||||
__all__ = [
|
||||
"GatewayService",
|
||||
"GatewayStatus",
|
||||
"GatewayInfo",
|
||||
"GatewayProtocol",
|
||||
"BaseGateway",
|
||||
"StripeGateway",
|
||||
"PayPalGateway",
|
||||
"BankTransferGateway",
|
||||
"gateway_service",
|
||||
]
|
||||
232
app/modules/payments/services/payment_service.py
Normal file
232
app/modules/payments/services/payment_service.py
Normal file
@@ -0,0 +1,232 @@
|
||||
# app/modules/payments/services/payment_service.py
|
||||
"""
|
||||
Payment service for processing payments through configured gateways.
|
||||
|
||||
This service provides a unified interface for payment operations
|
||||
regardless of the underlying gateway (Stripe, PayPal, etc.).
|
||||
|
||||
Usage:
|
||||
from app.modules.payments.services import PaymentService
|
||||
|
||||
payment_service = PaymentService()
|
||||
|
||||
# Process a payment
|
||||
result = await payment_service.process_payment(
|
||||
amount=1000, # Amount in cents
|
||||
currency="EUR",
|
||||
payment_method_id="pm_xxx",
|
||||
description="Order #123",
|
||||
)
|
||||
|
||||
# Issue a refund
|
||||
refund = await payment_service.refund(
|
||||
transaction_id="txn_xxx",
|
||||
amount=500, # Partial refund
|
||||
)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PaymentStatus(str, Enum):
|
||||
"""Payment transaction status."""
|
||||
|
||||
PENDING = "pending"
|
||||
PROCESSING = "processing"
|
||||
SUCCEEDED = "succeeded"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
REFUNDED = "refunded"
|
||||
PARTIALLY_REFUNDED = "partially_refunded"
|
||||
|
||||
|
||||
class PaymentGateway(str, Enum):
|
||||
"""Supported payment gateways."""
|
||||
|
||||
STRIPE = "stripe"
|
||||
PAYPAL = "paypal"
|
||||
BANK_TRANSFER = "bank_transfer"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PaymentResult:
|
||||
"""Result of a payment operation."""
|
||||
|
||||
success: bool
|
||||
transaction_id: str | None = None
|
||||
gateway: PaymentGateway | None = None
|
||||
status: PaymentStatus = PaymentStatus.PENDING
|
||||
amount: int = 0 # Amount in cents
|
||||
currency: str = "EUR"
|
||||
error_message: str | None = None
|
||||
gateway_response: dict[str, Any] | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"success": self.success,
|
||||
"transaction_id": self.transaction_id,
|
||||
"gateway": self.gateway.value if self.gateway else None,
|
||||
"status": self.status.value,
|
||||
"amount": self.amount,
|
||||
"currency": self.currency,
|
||||
"error_message": self.error_message,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class RefundResult:
|
||||
"""Result of a refund operation."""
|
||||
|
||||
success: bool
|
||||
refund_id: str | None = None
|
||||
transaction_id: str | None = None
|
||||
amount: int = 0
|
||||
status: PaymentStatus = PaymentStatus.PENDING
|
||||
error_message: str | None = None
|
||||
|
||||
|
||||
class PaymentService:
|
||||
"""
|
||||
Service for processing payments through configured gateways.
|
||||
|
||||
This service provides a unified interface for:
|
||||
- Processing payments
|
||||
- Issuing refunds
|
||||
- Managing payment methods
|
||||
- Retrieving transaction history
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._default_gateway = PaymentGateway.STRIPE
|
||||
|
||||
async def process_payment(
|
||||
self,
|
||||
amount: int,
|
||||
currency: str = "EUR",
|
||||
payment_method_id: str | None = None,
|
||||
gateway: PaymentGateway | None = None,
|
||||
description: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> PaymentResult:
|
||||
"""
|
||||
Process a payment through the specified gateway.
|
||||
|
||||
Args:
|
||||
amount: Amount in cents
|
||||
currency: Currency code (EUR, USD, etc.)
|
||||
payment_method_id: Stored payment method ID
|
||||
gateway: Payment gateway to use (default: stripe)
|
||||
description: Payment description
|
||||
metadata: Additional metadata for the transaction
|
||||
|
||||
Returns:
|
||||
PaymentResult with transaction details
|
||||
"""
|
||||
gateway = gateway or self._default_gateway
|
||||
|
||||
logger.info(
|
||||
f"Processing payment: {amount} {currency} via {gateway.value}",
|
||||
extra={"amount": amount, "currency": currency, "gateway": gateway.value},
|
||||
)
|
||||
|
||||
# TODO: Implement actual gateway processing
|
||||
# For now, return a mock successful result
|
||||
return PaymentResult(
|
||||
success=True,
|
||||
transaction_id=f"txn_{gateway.value}_mock",
|
||||
gateway=gateway,
|
||||
status=PaymentStatus.SUCCEEDED,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
async def refund(
|
||||
self,
|
||||
transaction_id: str,
|
||||
amount: int | None = None,
|
||||
reason: str | None = None,
|
||||
) -> RefundResult:
|
||||
"""
|
||||
Issue a refund for a transaction.
|
||||
|
||||
Args:
|
||||
transaction_id: Original transaction ID
|
||||
amount: Refund amount in cents (None for full refund)
|
||||
reason: Reason for refund
|
||||
|
||||
Returns:
|
||||
RefundResult with refund details
|
||||
"""
|
||||
logger.info(
|
||||
f"Issuing refund for transaction {transaction_id}",
|
||||
extra={"transaction_id": transaction_id, "amount": amount, "reason": reason},
|
||||
)
|
||||
|
||||
# TODO: Implement actual refund processing
|
||||
return RefundResult(
|
||||
success=True,
|
||||
refund_id=f"rf_{transaction_id}",
|
||||
transaction_id=transaction_id,
|
||||
amount=amount or 0,
|
||||
status=PaymentStatus.REFUNDED,
|
||||
)
|
||||
|
||||
async def get_transaction(self, transaction_id: str) -> dict[str, Any] | None:
|
||||
"""
|
||||
Get transaction details by ID.
|
||||
|
||||
Args:
|
||||
transaction_id: Transaction ID
|
||||
|
||||
Returns:
|
||||
Transaction details or None if not found
|
||||
"""
|
||||
# TODO: Implement transaction lookup
|
||||
return None
|
||||
|
||||
async def list_transactions(
|
||||
self,
|
||||
vendor_id: int | None = None,
|
||||
platform_id: int | None = None,
|
||||
status: PaymentStatus | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
List transactions with optional filters.
|
||||
|
||||
Args:
|
||||
vendor_id: Filter by vendor
|
||||
platform_id: Filter by platform
|
||||
status: Filter by status
|
||||
limit: Maximum results
|
||||
offset: Pagination offset
|
||||
|
||||
Returns:
|
||||
List of transaction records
|
||||
"""
|
||||
# TODO: Implement transaction listing
|
||||
return []
|
||||
|
||||
|
||||
# Singleton instance
|
||||
payment_service = PaymentService()
|
||||
|
||||
__all__ = [
|
||||
"PaymentService",
|
||||
"PaymentStatus",
|
||||
"PaymentGateway",
|
||||
"PaymentResult",
|
||||
"RefundResult",
|
||||
"payment_service",
|
||||
]
|
||||
@@ -2,14 +2,29 @@
|
||||
"""
|
||||
Module registry defining all available platform modules.
|
||||
|
||||
Each module bundles related features and menu items that can be
|
||||
enabled/disabled per platform. Core modules cannot be disabled.
|
||||
The module system uses a three-tier classification:
|
||||
|
||||
Module Granularity (Medium - ~12 modules):
|
||||
Matches menu sections for intuitive mapping between modules and UI.
|
||||
1. CORE MODULES (4) - Always enabled, cannot be disabled
|
||||
- core: Dashboard, settings, profile
|
||||
- tenancy: Platform, company, vendor, admin user management
|
||||
- cms: Content pages, media library, themes
|
||||
- customers: Customer database, profiles, segmentation
|
||||
|
||||
2. OPTIONAL MODULES (7) - Can be enabled/disabled per platform
|
||||
- payments: Payment gateway integrations (Stripe, PayPal, etc.)
|
||||
- billing: Platform subscriptions, vendor invoices (requires: payments)
|
||||
- inventory: Stock management, locations
|
||||
- orders: Order management, customer checkout (requires: payments)
|
||||
- marketplace: Letzshop integration (requires: inventory)
|
||||
- analytics: Reports, dashboards
|
||||
- messaging: Messages, notifications
|
||||
|
||||
3. INTERNAL MODULES (2) - Admin-only tools, not customer-facing
|
||||
- dev-tools: Component library, icons
|
||||
- monitoring: Logs, background tasks, Flower link, Grafana dashboards
|
||||
|
||||
Module Structure:
|
||||
- Inline modules: Defined directly in this file (core, platform-admin, etc.)
|
||||
- Inline modules: Defined directly in this file (core, tenancy)
|
||||
- Extracted modules: Imported from app/modules/{module}/ (billing, etc.)
|
||||
|
||||
As modules are extracted to their own directories, they are imported
|
||||
@@ -21,6 +36,7 @@ from models.database.admin_menu_config import FrontendType
|
||||
|
||||
# Import extracted modules
|
||||
from app.modules.billing.definition import billing_module
|
||||
from app.modules.payments.definition import payments_module
|
||||
from app.modules.inventory.definition import inventory_module
|
||||
from app.modules.marketplace.definition import marketplace_module
|
||||
from app.modules.orders.definition import orders_module
|
||||
@@ -33,13 +49,10 @@ from app.modules.monitoring.definition import monitoring_module
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Module Definitions
|
||||
# Core Modules (Always Enabled, Cannot Be Disabled)
|
||||
# =============================================================================
|
||||
|
||||
MODULES: dict[str, ModuleDefinition] = {
|
||||
# =========================================================================
|
||||
# Core Modules (Always Enabled)
|
||||
# =========================================================================
|
||||
CORE_MODULES: dict[str, ModuleDefinition] = {
|
||||
"core": ModuleDefinition(
|
||||
code="core",
|
||||
name="Core Platform",
|
||||
@@ -65,48 +78,67 @@ MODULES: dict[str, ModuleDefinition] = {
|
||||
],
|
||||
},
|
||||
),
|
||||
"platform-admin": ModuleDefinition(
|
||||
code="platform-admin",
|
||||
name="Platform Administration",
|
||||
description="Company, vendor, and admin user management. Required for multi-tenant operation.",
|
||||
"tenancy": ModuleDefinition(
|
||||
code="tenancy",
|
||||
name="Tenancy Management",
|
||||
description="Platform, company, vendor, and admin user management. Required for multi-tenant operation.",
|
||||
is_core=True,
|
||||
features=[
|
||||
"platform_management",
|
||||
"company_management",
|
||||
"vendor_management",
|
||||
"admin_user_management",
|
||||
"platform_management",
|
||||
],
|
||||
menu_items={
|
||||
FrontendType.ADMIN: [
|
||||
"admin-users",
|
||||
"platforms",
|
||||
"companies",
|
||||
"vendors",
|
||||
"platforms",
|
||||
"admin-users",
|
||||
],
|
||||
FrontendType.VENDOR: [
|
||||
"team",
|
||||
],
|
||||
},
|
||||
),
|
||||
# =========================================================================
|
||||
# Optional Modules
|
||||
# =========================================================================
|
||||
# CMS module - imported from app/modules/cms/
|
||||
"cms": cms_module,
|
||||
# Customers module - imported from app/modules/customers/
|
||||
"customers": customers_module,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Optional Modules (Can Be Enabled/Disabled Per Platform)
|
||||
# =============================================================================
|
||||
|
||||
OPTIONAL_MODULES: dict[str, ModuleDefinition] = {
|
||||
# Payments module - imported from app/modules/payments/
|
||||
# Gateway integrations (Stripe, PayPal, etc.)
|
||||
"payments": payments_module,
|
||||
# Billing module - imported from app/modules/billing/
|
||||
# Platform subscriptions, vendor invoices (requires: payments)
|
||||
"billing": billing_module,
|
||||
# Inventory module - imported from app/modules/inventory/
|
||||
"inventory": inventory_module,
|
||||
# Orders module - imported from app/modules/orders/
|
||||
# Order management, customer checkout (requires: payments)
|
||||
"orders": orders_module,
|
||||
# Marketplace module - imported from app/modules/marketplace/
|
||||
# Letzshop integration (requires: inventory)
|
||||
"marketplace": marketplace_module,
|
||||
# Customers module - imported from app/modules/customers/
|
||||
"customers": customers_module,
|
||||
# CMS module - imported from app/modules/cms/
|
||||
"cms": cms_module,
|
||||
# Analytics module - imported from app/modules/analytics/
|
||||
"analytics": analytics_module,
|
||||
# Messaging module - imported from app/modules/messaging/
|
||||
"messaging": messaging_module,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Internal Modules (Admin-Only, Not Customer-Facing)
|
||||
# =============================================================================
|
||||
|
||||
INTERNAL_MODULES: dict[str, ModuleDefinition] = {
|
||||
# Dev-Tools module - imported from app/modules/dev_tools/
|
||||
"dev-tools": dev_tools_module,
|
||||
# Monitoring module - imported from app/modules/monitoring/
|
||||
@@ -114,6 +146,17 @@ MODULES: dict[str, ModuleDefinition] = {
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Combined Module Registry
|
||||
# =============================================================================
|
||||
|
||||
MODULES: dict[str, ModuleDefinition] = {
|
||||
**CORE_MODULES,
|
||||
**OPTIONAL_MODULES,
|
||||
**INTERNAL_MODULES,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper Functions
|
||||
# =============================================================================
|
||||
@@ -126,17 +169,32 @@ def get_module(code: str) -> ModuleDefinition | None:
|
||||
|
||||
def get_core_modules() -> list[ModuleDefinition]:
|
||||
"""Get all core modules (cannot be disabled)."""
|
||||
return [m for m in MODULES.values() if m.is_core]
|
||||
return list(CORE_MODULES.values())
|
||||
|
||||
|
||||
def get_core_module_codes() -> set[str]:
|
||||
"""Get codes of all core modules."""
|
||||
return {m.code for m in MODULES.values() if m.is_core}
|
||||
return set(CORE_MODULES.keys())
|
||||
|
||||
|
||||
def get_optional_modules() -> list[ModuleDefinition]:
|
||||
"""Get all optional modules (can be enabled/disabled)."""
|
||||
return [m for m in MODULES.values() if not m.is_core]
|
||||
return list(OPTIONAL_MODULES.values())
|
||||
|
||||
|
||||
def get_optional_module_codes() -> set[str]:
|
||||
"""Get codes of all optional modules."""
|
||||
return set(OPTIONAL_MODULES.keys())
|
||||
|
||||
|
||||
def get_internal_modules() -> list[ModuleDefinition]:
|
||||
"""Get all internal modules (admin-only tools)."""
|
||||
return list(INTERNAL_MODULES.values())
|
||||
|
||||
|
||||
def get_internal_module_codes() -> set[str]:
|
||||
"""Get codes of all internal modules."""
|
||||
return set(INTERNAL_MODULES.keys())
|
||||
|
||||
|
||||
def get_all_module_codes() -> set[str]:
|
||||
@@ -144,6 +202,16 @@ def get_all_module_codes() -> set[str]:
|
||||
return set(MODULES.keys())
|
||||
|
||||
|
||||
def is_core_module(code: str) -> bool:
|
||||
"""Check if a module is a core module."""
|
||||
return code in CORE_MODULES
|
||||
|
||||
|
||||
def is_internal_module(code: str) -> bool:
|
||||
"""Check if a module is an internal module."""
|
||||
return code in INTERNAL_MODULES
|
||||
|
||||
|
||||
def get_menu_item_module(menu_item_id: str, frontend_type: FrontendType) -> str | None:
|
||||
"""
|
||||
Find which module provides a specific menu item.
|
||||
@@ -202,6 +270,39 @@ def validate_module_dependencies() -> list[str]:
|
||||
return errors
|
||||
|
||||
|
||||
def get_modules_by_tier() -> dict[str, list[ModuleDefinition]]:
|
||||
"""
|
||||
Get modules organized by tier.
|
||||
|
||||
Returns:
|
||||
Dict with keys 'core', 'optional', 'internal' mapping to module lists
|
||||
"""
|
||||
return {
|
||||
"core": list(CORE_MODULES.values()),
|
||||
"optional": list(OPTIONAL_MODULES.values()),
|
||||
"internal": list(INTERNAL_MODULES.values()),
|
||||
}
|
||||
|
||||
|
||||
def get_module_tier(code: str) -> str | None:
|
||||
"""
|
||||
Get the tier classification of a module.
|
||||
|
||||
Args:
|
||||
code: Module code
|
||||
|
||||
Returns:
|
||||
'core', 'optional', 'internal', or None if not found
|
||||
"""
|
||||
if code in CORE_MODULES:
|
||||
return "core"
|
||||
elif code in OPTIONAL_MODULES:
|
||||
return "optional"
|
||||
elif code in INTERNAL_MODULES:
|
||||
return "internal"
|
||||
return None
|
||||
|
||||
|
||||
# Validate dependencies on import (development check)
|
||||
_validation_errors = validate_module_dependencies()
|
||||
if _validation_errors:
|
||||
@@ -212,13 +313,28 @@ if _validation_errors:
|
||||
|
||||
|
||||
__all__ = [
|
||||
# Module dictionaries
|
||||
"MODULES",
|
||||
"CORE_MODULES",
|
||||
"OPTIONAL_MODULES",
|
||||
"INTERNAL_MODULES",
|
||||
# Module retrieval
|
||||
"get_module",
|
||||
"get_core_modules",
|
||||
"get_core_module_codes",
|
||||
"get_optional_modules",
|
||||
"get_optional_module_codes",
|
||||
"get_internal_modules",
|
||||
"get_internal_module_codes",
|
||||
"get_all_module_codes",
|
||||
# Module classification
|
||||
"is_core_module",
|
||||
"is_internal_module",
|
||||
"get_modules_by_tier",
|
||||
"get_module_tier",
|
||||
# Menu and feature lookup
|
||||
"get_menu_item_module",
|
||||
"get_feature_module",
|
||||
# Validation
|
||||
"validate_module_dependencies",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user