diff --git a/alembic/versions/zc2m3n4o5p6q7_add_platform_modules_table.py b/alembic/versions/zc2m3n4o5p6q7_add_platform_modules_table.py new file mode 100644 index 00000000..4f31fc16 --- /dev/null +++ b/alembic/versions/zc2m3n4o5p6q7_add_platform_modules_table.py @@ -0,0 +1,142 @@ +"""Add platform modules table + +Revision ID: zc2m3n4o5p6q7 +Revises: zb1l2m3n4o5p6 +Create Date: 2026-01-26 + +Adds platform_modules junction table for tracking module enablement per platform: +- Auditability: Track when modules were enabled/disabled and by whom +- Configuration: Per-module settings specific to each platform +- State tracking: Explicit enabled/disabled states with timestamps + +This replaces the simpler Platform.settings["enabled_modules"] JSON approach +for better auditability and query capabilities. +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "zc2m3n4o5p6q7" +down_revision = "zb1l2m3n4o5p6" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Create platform_modules table + op.create_table( + "platform_modules", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column( + "platform_id", + sa.Integer(), + nullable=False, + comment="Platform this module configuration belongs to", + ), + sa.Column( + "module_code", + sa.String(50), + nullable=False, + comment="Module code (e.g., 'billing', 'inventory', 'orders')", + ), + sa.Column( + "is_enabled", + sa.Boolean(), + nullable=False, + server_default="true", + comment="Whether this module is currently enabled for the platform", + ), + sa.Column( + "enabled_at", + sa.DateTime(timezone=True), + nullable=True, + comment="When the module was last enabled", + ), + sa.Column( + "enabled_by_user_id", + sa.Integer(), + nullable=True, + comment="User who enabled the module", + ), + sa.Column( + "disabled_at", + sa.DateTime(timezone=True), + nullable=True, + comment="When the module was last disabled", + ), + sa.Column( + "disabled_by_user_id", + sa.Integer(), + nullable=True, + comment="User who disabled the module", + ), + sa.Column( + "config", + sa.JSON(), + nullable=False, + server_default="{}", + comment="Module-specific configuration for this platform", + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + onupdate=sa.func.now(), + ), + # Primary key + sa.PrimaryKeyConstraint("id"), + # Foreign keys + sa.ForeignKeyConstraint( + ["platform_id"], + ["platforms.id"], + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["enabled_by_user_id"], + ["users.id"], + ondelete="SET NULL", + ), + sa.ForeignKeyConstraint( + ["disabled_by_user_id"], + ["users.id"], + ondelete="SET NULL", + ), + # Unique constraint - one config per platform/module pair + sa.UniqueConstraint("platform_id", "module_code", name="uq_platform_module"), + ) + + # Create indexes for performance + op.create_index( + "idx_platform_module_platform_id", + "platform_modules", + ["platform_id"], + ) + op.create_index( + "idx_platform_module_code", + "platform_modules", + ["module_code"], + ) + op.create_index( + "idx_platform_module_enabled", + "platform_modules", + ["platform_id", "is_enabled"], + ) + + +def downgrade() -> None: + # Drop indexes + op.drop_index("idx_platform_module_enabled", table_name="platform_modules") + op.drop_index("idx_platform_module_code", table_name="platform_modules") + op.drop_index("idx_platform_module_platform_id", table_name="platform_modules") + + # Drop table + op.drop_table("platform_modules") diff --git a/app/api/v1/admin/__init__.py b/app/api/v1/admin/__init__.py index a487b5f2..5296609e 100644 --- a/app/api/v1/admin/__init__.py +++ b/app/api/v1/admin/__init__.py @@ -61,6 +61,7 @@ from . import ( media, menu_config, messages, + module_config, modules, monitoring, notifications, @@ -80,8 +81,9 @@ from . import ( ) # Import extracted module routers -from app.modules.billing.routes import admin_router as billing_admin_router -from app.modules.inventory.routes import admin_router as inventory_admin_router +# NOTE: Import directly from admin.py files to avoid circular imports through __init__.py +from app.modules.billing.routes.admin import admin_router as billing_admin_router +from app.modules.inventory.routes.admin import admin_router as inventory_admin_router from app.modules.orders.routes.admin import admin_router as orders_admin_router from app.modules.orders.routes.admin import admin_exceptions_router as orders_exceptions_router from app.modules.marketplace.routes.admin import admin_router as marketplace_admin_router @@ -129,6 +131,9 @@ router.include_router(menu_config.router, tags=["admin-menu-config"]) # Include module management endpoints (super admin only) router.include_router(modules.router, tags=["admin-modules"]) +# Include module configuration endpoints (super admin only) +router.include_router(module_config.router, tags=["admin-module-config"]) + # ============================================================================ # User Management diff --git a/app/api/v1/admin/menu_config.py b/app/api/v1/admin/menu_config.py new file mode 100644 index 00000000..3517979b --- /dev/null +++ b/app/api/v1/admin/menu_config.py @@ -0,0 +1,463 @@ +# app/api/v1/admin/menu_config.py +""" +Admin API endpoints for Platform Menu Configuration. + +Provides menu visibility configuration for admin and vendor frontends: +- GET /menu-config/platforms/{platform_id} - Get menu config for a platform +- PUT /menu-config/platforms/{platform_id} - Update menu visibility for a platform +- POST /menu-config/platforms/{platform_id}/reset - Reset to defaults +- GET /menu-config/user - Get current user's menu config (super admins) +- PUT /menu-config/user - Update current user's menu config (super admins) +- GET /menu/admin - Get rendered admin menu for current user +- GET /menu/vendor - Get rendered vendor menu for current platform + +All configuration endpoints require super admin access. +Menu rendering endpoints require authenticated admin/vendor access. +""" + +import logging +from typing import Any + +from fastapi import APIRouter, Depends, Path, Query +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.api.deps import ( + get_current_admin_from_cookie_or_header, + get_current_super_admin, + get_db, +) +from app.services.menu_service import MenuItemConfig, menu_service +from app.services.platform_service import platform_service +from models.database.admin_menu_config import FrontendType +from models.database.user import User + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/menu-config") + + +# ============================================================================= +# Pydantic Schemas +# ============================================================================= + + +class MenuItemResponse(BaseModel): + """Menu item configuration response.""" + + id: str + label: str + icon: str + url: str + section_id: str + section_label: str | None = None + is_visible: bool = True + is_mandatory: bool = False + is_super_admin_only: bool = False + + +class MenuConfigResponse(BaseModel): + """Menu configuration response for a platform or user.""" + + frontend_type: str + platform_id: int | None = None + user_id: int | None = None + items: list[MenuItemResponse] + total_items: int + visible_items: int + hidden_items: int + + +class MenuVisibilityUpdateRequest(BaseModel): + """Request to update menu item visibility.""" + + menu_item_id: str = Field(..., description="Menu item ID to update") + is_visible: bool = Field(..., description="Whether the item should be visible") + + +class BulkMenuVisibilityUpdateRequest(BaseModel): + """Request to update multiple menu items at once.""" + + visibility: dict[str, bool] = Field( + ..., + description="Map of menu_item_id to is_visible", + examples=[{"inventory": False, "orders": True}], + ) + + +class MenuSectionResponse(BaseModel): + """Menu section for rendering.""" + + id: str + label: str | None = None + items: list[dict[str, Any]] + + +class RenderedMenuResponse(BaseModel): + """Rendered menu for frontend.""" + + frontend_type: str + sections: list[MenuSectionResponse] + + +class MenuActionResponse(BaseModel): + """Response for menu action operations (reset, show-all, etc.).""" + + success: bool + message: str + + +# ============================================================================= +# Helper Functions +# ============================================================================= + + +def _build_menu_item_response(item: MenuItemConfig) -> MenuItemResponse: + """Convert MenuItemConfig to API response.""" + return MenuItemResponse( + id=item.id, + label=item.label, + icon=item.icon, + url=item.url, + section_id=item.section_id, + section_label=item.section_label, + is_visible=item.is_visible, + is_mandatory=item.is_mandatory, + is_super_admin_only=item.is_super_admin_only, + ) + + +def _build_menu_config_response( + items: list[MenuItemConfig], + frontend_type: FrontendType, + platform_id: int | None = None, + user_id: int | None = None, +) -> MenuConfigResponse: + """Build menu configuration response.""" + item_responses = [_build_menu_item_response(item) for item in items] + visible_count = sum(1 for item in items if item.is_visible) + + return MenuConfigResponse( + frontend_type=frontend_type.value, + platform_id=platform_id, + user_id=user_id, + items=item_responses, + total_items=len(items), + visible_items=visible_count, + hidden_items=len(items) - visible_count, + ) + + +# ============================================================================= +# Platform Menu Configuration (Super Admin Only) +# ============================================================================= + + +@router.get("/platforms/{platform_id}", response_model=MenuConfigResponse) +async def get_platform_menu_config( + platform_id: int = Path(..., description="Platform ID"), + frontend_type: FrontendType = Query( + FrontendType.ADMIN, description="Frontend type (admin or vendor)" + ), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_super_admin), +): + """ + Get menu configuration for a platform. + + Returns all menu items with their visibility status for the specified + platform and frontend type. Super admin only. + """ + # Verify platform exists + platform = platform_service.get_platform_by_id(db, platform_id) + + items = menu_service.get_platform_menu_config(db, frontend_type, platform_id) + + logger.info( + f"[MENU_CONFIG] Super admin {current_user.email} fetched menu config " + f"for platform {platform.code} ({frontend_type.value})" + ) + + return _build_menu_config_response(items, frontend_type, platform_id=platform_id) + + +@router.put("/platforms/{platform_id}") +async def update_platform_menu_visibility( + update_data: MenuVisibilityUpdateRequest, + platform_id: int = Path(..., description="Platform ID"), + frontend_type: FrontendType = Query( + FrontendType.ADMIN, description="Frontend type (admin or vendor)" + ), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_super_admin), +): + """ + Update visibility for a single menu item for a platform. + + Super admin only. Cannot hide mandatory items. + """ + # Verify platform exists + platform = platform_service.get_platform_by_id(db, platform_id) + + menu_service.update_menu_visibility( + db=db, + frontend_type=frontend_type, + menu_item_id=update_data.menu_item_id, + is_visible=update_data.is_visible, + platform_id=platform_id, + ) + db.commit() + + logger.info( + f"[MENU_CONFIG] Super admin {current_user.email} updated menu visibility: " + f"{update_data.menu_item_id}={update_data.is_visible} " + f"for platform {platform.code} ({frontend_type.value})" + ) + + return {"success": True, "message": "Menu visibility updated"} + + +@router.put("/platforms/{platform_id}/bulk") +async def bulk_update_platform_menu_visibility( + update_data: BulkMenuVisibilityUpdateRequest, + platform_id: int = Path(..., description="Platform ID"), + frontend_type: FrontendType = Query( + FrontendType.ADMIN, description="Frontend type (admin or vendor)" + ), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_super_admin), +): + """ + Update visibility for multiple menu items at once. + + Super admin only. Skips mandatory items silently. + """ + # Verify platform exists + platform = platform_service.get_platform_by_id(db, platform_id) + + menu_service.bulk_update_menu_visibility( + db=db, + frontend_type=frontend_type, + visibility_map=update_data.visibility, + platform_id=platform_id, + ) + db.commit() + + logger.info( + f"[MENU_CONFIG] Super admin {current_user.email} bulk updated menu visibility: " + f"{len(update_data.visibility)} items for platform {platform.code} ({frontend_type.value})" + ) + + return {"success": True, "message": f"Updated {len(update_data.visibility)} menu items"} + + +@router.post("/platforms/{platform_id}/reset") +async def reset_platform_menu_config( + platform_id: int = Path(..., description="Platform ID"), + frontend_type: FrontendType = Query( + FrontendType.ADMIN, description="Frontend type (admin or vendor)" + ), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_super_admin), +): + """ + Reset menu configuration for a platform to defaults. + + Removes all visibility overrides, making all items visible. + Super admin only. + """ + # Verify platform exists + platform = platform_service.get_platform_by_id(db, platform_id) + + menu_service.reset_platform_menu_config(db, frontend_type, platform_id) + db.commit() + + logger.info( + f"[MENU_CONFIG] Super admin {current_user.email} reset menu config " + f"for platform {platform.code} ({frontend_type.value})" + ) + + return {"success": True, "message": "Menu configuration reset to defaults"} + + +# ============================================================================= +# User Menu Configuration (Super Admin Only) +# ============================================================================= + + +@router.get("/user", response_model=MenuConfigResponse) +async def get_user_menu_config( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_super_admin), +): + """ + Get the current super admin's personal menu configuration. + + Only super admins can configure their own admin menu. + """ + items = menu_service.get_user_menu_config(db, current_user.id) + + logger.info( + f"[MENU_CONFIG] Super admin {current_user.email} fetched their personal menu config" + ) + + return _build_menu_config_response( + items, FrontendType.ADMIN, user_id=current_user.id + ) + + +@router.put("/user") +async def update_user_menu_visibility( + update_data: MenuVisibilityUpdateRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_super_admin), +): + """ + Update visibility for a single menu item for the current super admin. + + Super admin only. Cannot hide mandatory items. + """ + menu_service.update_menu_visibility( + db=db, + frontend_type=FrontendType.ADMIN, + menu_item_id=update_data.menu_item_id, + is_visible=update_data.is_visible, + user_id=current_user.id, + ) + db.commit() + + logger.info( + f"[MENU_CONFIG] Super admin {current_user.email} updated personal menu: " + f"{update_data.menu_item_id}={update_data.is_visible}" + ) + + return {"success": True, "message": "Menu visibility updated"} + + +@router.post("/user/reset", response_model=MenuActionResponse) +async def reset_user_menu_config( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_super_admin), +): + """ + Reset the current super admin's menu configuration (hide all except mandatory). + + Super admin only. + """ + menu_service.reset_user_menu_config(db, current_user.id) + db.commit() + + logger.info( + f"[MENU_CONFIG] Super admin {current_user.email} reset their personal menu config (hide all)" + ) + + return MenuActionResponse(success=True, message="Menu configuration reset - all items hidden") + + +@router.post("/user/show-all", response_model=MenuActionResponse) +async def show_all_user_menu_config( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_super_admin), +): + """ + Show all menu items for the current super admin. + + Super admin only. + """ + menu_service.show_all_user_menu_config(db, current_user.id) + db.commit() + + logger.info( + f"[MENU_CONFIG] Super admin {current_user.email} enabled all menu items" + ) + + return MenuActionResponse(success=True, message="All menu items are now visible") + + +@router.post("/platforms/{platform_id}/show-all") +async def show_all_platform_menu_config( + platform_id: int = Path(..., description="Platform ID"), + frontend_type: FrontendType = Query( + FrontendType.ADMIN, description="Frontend type (admin or vendor)" + ), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_super_admin), +): + """ + Show all menu items for a platform. + + Super admin only. + """ + # Verify platform exists + platform = platform_service.get_platform_by_id(db, platform_id) + + menu_service.show_all_platform_menu_config(db, frontend_type, platform_id) + db.commit() + + logger.info( + f"[MENU_CONFIG] Super admin {current_user.email} enabled all menu items " + f"for platform {platform.code} ({frontend_type.value})" + ) + + return {"success": True, "message": "All menu items are now visible"} + + +# ============================================================================= +# Menu Rendering (For Sidebar) +# ============================================================================= + + +@router.get("/render/admin", response_model=RenderedMenuResponse) +async def get_rendered_admin_menu( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_admin_from_cookie_or_header), +): + """ + Get the rendered admin menu for the current user. + + Returns the filtered menu structure based on: + - Super admins: user-level config + - Platform admins: platform-level config + + Used by the frontend to render the sidebar. + """ + if current_user.is_super_admin: + # Super admin: use user-level config + menu = menu_service.get_menu_for_rendering( + db=db, + frontend_type=FrontendType.ADMIN, + user_id=current_user.id, + is_super_admin=True, + ) + else: + # Platform admin: use platform-level config + # Get the selected platform from the JWT token + platform_id = getattr(current_user, "token_platform_id", None) + + # Fallback to first platform if no platform in token (shouldn't happen) + if platform_id is None and current_user.admin_platforms: + platform_id = current_user.admin_platforms[0].id + logger.warning( + f"[MENU_CONFIG] No platform_id in token for {current_user.email}, " + f"falling back to first platform: {platform_id}" + ) + + menu = menu_service.get_menu_for_rendering( + db=db, + frontend_type=FrontendType.ADMIN, + platform_id=platform_id, + is_super_admin=False, + ) + + sections = [ + MenuSectionResponse( + id=section["id"], + label=section.get("label"), + items=section["items"], + ) + for section in menu.get("sections", []) + ] + + return RenderedMenuResponse( + frontend_type=FrontendType.ADMIN.value, + sections=sections, + ) diff --git a/app/api/v1/admin/module_config.py b/app/api/v1/admin/module_config.py new file mode 100644 index 00000000..ceb7845a --- /dev/null +++ b/app/api/v1/admin/module_config.py @@ -0,0 +1,425 @@ +# app/api/v1/admin/module_config.py +""" +Admin API endpoints for Module Configuration Management. + +Provides per-module configuration for platforms: +- GET /module-config/platforms/{platform_id}/modules/{module_code}/config - Get module config +- PUT /module-config/platforms/{platform_id}/modules/{module_code}/config - Update module config +- GET /module-config/defaults/{module_code} - Get config defaults for a module + +All endpoints require super admin access. +""" + +import logging +from typing import Any + +from fastapi import APIRouter, Depends, Path +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.api.deps import get_current_super_admin, get_db +from app.modules.registry import MODULES +from app.modules.service import module_service +from app.services.platform_service import platform_service +from models.database.user import User + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/module-config") + + +# ============================================================================= +# Config Defaults per Module +# ============================================================================= + +# Default configuration options per module +MODULE_CONFIG_DEFAULTS: dict[str, dict[str, Any]] = { + "billing": { + "stripe_mode": "test", + "default_trial_days": 14, + "allow_free_tier": True, + }, + "inventory": { + "low_stock_threshold": 10, + "enable_locations": False, + }, + "orders": { + "require_payment": True, + "auto_archive_days": 90, + }, + "marketplace": { + "sync_frequency_hours": 24, + "auto_import_products": False, + }, + "customers": { + "enable_segmentation": True, + "marketing_consent_default": False, + }, + "cms": { + "max_pages": 50, + "enable_seo": True, + }, + "analytics": { + "data_retention_days": 365, + "enable_export": True, + }, + "messaging": { + "enable_attachments": True, + "max_attachment_size_mb": 10, + }, + "monitoring": { + "log_retention_days": 30, + "alert_email": "", + }, +} + +# Config option metadata for UI rendering +MODULE_CONFIG_SCHEMA: dict[str, list[dict[str, Any]]] = { + "billing": [ + { + "key": "stripe_mode", + "label": "Stripe Mode", + "type": "select", + "options": ["test", "live"], + "description": "Use test or live Stripe API keys", + }, + { + "key": "default_trial_days", + "label": "Default Trial Days", + "type": "number", + "min": 0, + "max": 90, + "description": "Number of trial days for new subscriptions", + }, + { + "key": "allow_free_tier", + "label": "Allow Free Tier", + "type": "boolean", + "description": "Allow vendors to use free tier indefinitely", + }, + ], + "inventory": [ + { + "key": "low_stock_threshold", + "label": "Low Stock Threshold", + "type": "number", + "min": 0, + "max": 1000, + "description": "Stock level below which low stock alerts trigger", + }, + { + "key": "enable_locations", + "label": "Enable Locations", + "type": "boolean", + "description": "Enable multiple inventory locations", + }, + ], + "orders": [ + { + "key": "require_payment", + "label": "Require Payment", + "type": "boolean", + "description": "Require payment before order confirmation", + }, + { + "key": "auto_archive_days", + "label": "Auto Archive Days", + "type": "number", + "min": 30, + "max": 365, + "description": "Days after which completed orders are archived", + }, + ], + "marketplace": [ + { + "key": "sync_frequency_hours", + "label": "Sync Frequency (hours)", + "type": "number", + "min": 1, + "max": 168, + "description": "How often to sync with external marketplaces", + }, + { + "key": "auto_import_products", + "label": "Auto Import Products", + "type": "boolean", + "description": "Automatically import new products from marketplace", + }, + ], + "customers": [ + { + "key": "enable_segmentation", + "label": "Enable Segmentation", + "type": "boolean", + "description": "Enable customer segmentation and tagging", + }, + { + "key": "marketing_consent_default", + "label": "Marketing Consent Default", + "type": "boolean", + "description": "Default value for marketing consent checkbox", + }, + ], + "cms": [ + { + "key": "max_pages", + "label": "Max Pages", + "type": "number", + "min": 1, + "max": 500, + "description": "Maximum number of content pages allowed", + }, + { + "key": "enable_seo", + "label": "Enable SEO", + "type": "boolean", + "description": "Enable SEO fields for content pages", + }, + ], + "analytics": [ + { + "key": "data_retention_days", + "label": "Data Retention (days)", + "type": "number", + "min": 30, + "max": 730, + "description": "How long to keep analytics data", + }, + { + "key": "enable_export", + "label": "Enable Export", + "type": "boolean", + "description": "Allow exporting analytics data", + }, + ], + "messaging": [ + { + "key": "enable_attachments", + "label": "Enable Attachments", + "type": "boolean", + "description": "Allow file attachments in messages", + }, + { + "key": "max_attachment_size_mb", + "label": "Max Attachment Size (MB)", + "type": "number", + "min": 1, + "max": 50, + "description": "Maximum attachment file size in megabytes", + }, + ], + "monitoring": [ + { + "key": "log_retention_days", + "label": "Log Retention (days)", + "type": "number", + "min": 7, + "max": 365, + "description": "How long to keep log files", + }, + { + "key": "alert_email", + "label": "Alert Email", + "type": "string", + "description": "Email address for system alerts (blank to disable)", + }, + ], +} + + +# ============================================================================= +# Pydantic Schemas +# ============================================================================= + + +class ModuleConfigResponse(BaseModel): + """Module configuration response.""" + + module_code: str + module_name: str + config: dict[str, Any] + schema_info: list[dict[str, Any]] = Field(default_factory=list) + defaults: dict[str, Any] = Field(default_factory=dict) + + +class UpdateConfigRequest(BaseModel): + """Request to update module configuration.""" + + config: dict[str, Any] = Field(..., description="Configuration key-value pairs") + + +class ConfigDefaultsResponse(BaseModel): + """Response for module config defaults.""" + + module_code: str + module_name: str + defaults: dict[str, Any] + schema_info: list[dict[str, Any]] + + +# ============================================================================= +# API Endpoints +# ============================================================================= + + +@router.get("/platforms/{platform_id}/modules/{module_code}/config", response_model=ModuleConfigResponse) +async def get_module_config( + platform_id: int = Path(..., description="Platform ID"), + module_code: str = Path(..., description="Module code"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_super_admin), +): + """ + Get configuration for a specific module on a platform. + + Returns current config values merged with defaults. + Super admin only. + """ + from app.exceptions import BadRequestException + + # Verify platform exists + platform = platform_service.get_platform_by_id(db, platform_id) + + # Validate module code + if module_code not in MODULES: + raise BadRequestException(f"Unknown module: {module_code}") + + module = MODULES[module_code] + + # Get current config + current_config = module_service.get_module_config(db, platform_id, module_code) + + # Merge with defaults + defaults = MODULE_CONFIG_DEFAULTS.get(module_code, {}) + merged_config = {**defaults, **current_config} + + logger.info( + f"[MODULE_CONFIG] Super admin {current_user.email} fetched config " + f"for module '{module_code}' on platform {platform.code}" + ) + + return ModuleConfigResponse( + module_code=module_code, + module_name=module.name, + config=merged_config, + schema_info=MODULE_CONFIG_SCHEMA.get(module_code, []), + defaults=defaults, + ) + + +@router.put("/platforms/{platform_id}/modules/{module_code}/config", response_model=ModuleConfigResponse) +async def update_module_config( + update_data: UpdateConfigRequest, + platform_id: int = Path(..., description="Platform ID"), + module_code: str = Path(..., description="Module code"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_super_admin), +): + """ + Update configuration for a specific module on a platform. + + Super admin only. + """ + from app.exceptions import BadRequestException + + # Verify platform exists + platform = platform_service.get_platform_by_id(db, platform_id) + + # Validate module code + if module_code not in MODULES: + raise BadRequestException(f"Unknown module: {module_code}") + + module = MODULES[module_code] + + # Update config + success = module_service.set_module_config(db, platform_id, module_code, update_data.config) + if success: + db.commit() + + # Get updated config + current_config = module_service.get_module_config(db, platform_id, module_code) + defaults = MODULE_CONFIG_DEFAULTS.get(module_code, {}) + merged_config = {**defaults, **current_config} + + logger.info( + f"[MODULE_CONFIG] Super admin {current_user.email} updated config " + f"for module '{module_code}' on platform {platform.code}: {update_data.config}" + ) + + return ModuleConfigResponse( + module_code=module_code, + module_name=module.name, + config=merged_config, + schema_info=MODULE_CONFIG_SCHEMA.get(module_code, []), + defaults=defaults, + ) + + +@router.get("/defaults/{module_code}", response_model=ConfigDefaultsResponse) +async def get_config_defaults( + module_code: str = Path(..., description="Module code"), + current_user: User = Depends(get_current_super_admin), +): + """ + Get default configuration for a module. + + Returns the default config values and schema for a module. + Super admin only. + """ + from app.exceptions import BadRequestException + + # Validate module code + if module_code not in MODULES: + raise BadRequestException(f"Unknown module: {module_code}") + + module = MODULES[module_code] + + logger.info( + f"[MODULE_CONFIG] Super admin {current_user.email} fetched defaults " + f"for module '{module_code}'" + ) + + return ConfigDefaultsResponse( + module_code=module_code, + module_name=module.name, + defaults=MODULE_CONFIG_DEFAULTS.get(module_code, {}), + schema_info=MODULE_CONFIG_SCHEMA.get(module_code, []), + ) + + +@router.post("/platforms/{platform_id}/modules/{module_code}/reset") +async def reset_module_config( + platform_id: int = Path(..., description="Platform ID"), + module_code: str = Path(..., description="Module code"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_super_admin), +): + """ + Reset module configuration to defaults. + + Super admin only. + """ + from app.exceptions import BadRequestException + + # Verify platform exists + platform = platform_service.get_platform_by_id(db, platform_id) + + # Validate module code + if module_code not in MODULES: + raise BadRequestException(f"Unknown module: {module_code}") + + # Reset to defaults + defaults = MODULE_CONFIG_DEFAULTS.get(module_code, {}) + success = module_service.set_module_config(db, platform_id, module_code, defaults) + if success: + db.commit() + + logger.info( + f"[MODULE_CONFIG] Super admin {current_user.email} reset config " + f"for module '{module_code}' on platform {platform.code} to defaults" + ) + + return { + "success": success, + "message": f"Module '{module_code}' config reset to defaults", + "config": defaults, + } diff --git a/app/api/v1/vendor/__init__.py b/app/api/v1/vendor/__init__.py index b111c372..1ad06858 100644 --- a/app/api/v1/vendor/__init__.py +++ b/app/api/v1/vendor/__init__.py @@ -8,6 +8,23 @@ IMPORTANT: - This router is for JSON API endpoints only - HTML page routes are mounted separately in main.py at /vendor/* - Do NOT include pages.router here - it causes route conflicts + +MODULE SYSTEM: +Routes can be module-gated using require_module_access() dependency. +For multi-tenant apps, module enablement is checked at request time +based on platform context (not at route registration time). + +Extracted modules (app/modules/{module}/routes/): +- billing: Subscription tiers, vendor billing, invoices +- inventory: Stock management, inventory tracking +- orders: Order management, fulfillment, exceptions +- marketplace: Letzshop integration, product sync + +Module extraction pattern: +1. Create app/modules/{module}/ directory +2. Create routes/vendor.py with require_module_access("{module}") dependency +3. Import module router here and include it +4. Comment out legacy router include """ from fastapi import APIRouter @@ -42,6 +59,15 @@ from . import ( usage, ) +# Import extracted module routers +# NOTE: Import directly from vendor.py files to avoid circular imports through __init__.py +from app.modules.billing.routes.vendor import vendor_router as billing_vendor_router +from app.modules.inventory.routes.vendor import vendor_router as inventory_vendor_router +from app.modules.orders.routes.vendor import vendor_router as orders_vendor_router +from app.modules.orders.routes.vendor import vendor_exceptions_router as orders_exceptions_router +from app.modules.marketplace.routes.vendor import vendor_router as marketplace_vendor_router +from app.modules.marketplace.routes.vendor import vendor_letzshop_router as letzshop_vendor_router + # Create vendor router router = APIRouter() @@ -67,14 +93,26 @@ router.include_router(onboarding.router, tags=["vendor-onboarding"]) # Business operations (with prefixes: /products/*, /orders/*, etc.) router.include_router(products.router, tags=["vendor-products"]) -router.include_router(orders.router, tags=["vendor-orders"]) -router.include_router(order_item_exceptions.router, tags=["vendor-order-exceptions"]) + +# Include orders module router (with module access control) +router.include_router(orders_vendor_router, tags=["vendor-orders"]) +router.include_router(orders_exceptions_router, tags=["vendor-order-exceptions"]) +# Legacy: router.include_router(orders.router, tags=["vendor-orders"]) +# Legacy: router.include_router(order_item_exceptions.router, tags=["vendor-order-exceptions"]) + router.include_router(invoices.router, tags=["vendor-invoices"]) router.include_router(customers.router, tags=["vendor-customers"]) router.include_router(team.router, tags=["vendor-team"]) -router.include_router(inventory.router, tags=["vendor-inventory"]) -router.include_router(marketplace.router, tags=["vendor-marketplace"]) -router.include_router(letzshop.router, tags=["vendor-letzshop"]) + +# Include inventory module router (with module access control) +router.include_router(inventory_vendor_router, tags=["vendor-inventory"]) +# Legacy: router.include_router(inventory.router, tags=["vendor-inventory"]) + +# Include marketplace module router (with module access control) +router.include_router(marketplace_vendor_router, tags=["vendor-marketplace"]) +router.include_router(letzshop_vendor_router, tags=["vendor-letzshop"]) +# Legacy: router.include_router(marketplace.router, tags=["vendor-marketplace"]) +# Legacy: router.include_router(letzshop.router, tags=["vendor-letzshop"]) # Services (with prefixes: /payments/*, /media/*, etc.) router.include_router(payments.router, tags=["vendor-payments"]) @@ -82,7 +120,11 @@ router.include_router(media.router, tags=["vendor-media"]) router.include_router(notifications.router, tags=["vendor-notifications"]) router.include_router(messages.router, tags=["vendor-messages"]) router.include_router(analytics.router, tags=["vendor-analytics"]) -router.include_router(billing.router, tags=["vendor-billing"]) + +# Include billing module router (with module access control) +router.include_router(billing_vendor_router, tags=["vendor-billing"]) +# Legacy: router.include_router(billing.router, tags=["vendor-billing"]) + router.include_router(features.router, tags=["vendor-features"]) router.include_router(usage.router, tags=["vendor-usage"]) diff --git a/app/modules/analytics/__init__.py b/app/modules/analytics/__init__.py new file mode 100644 index 00000000..93d52214 --- /dev/null +++ b/app/modules/analytics/__init__.py @@ -0,0 +1,22 @@ +# app/modules/analytics/__init__.py +""" +Analytics Module - Reporting and analytics. + +This module provides: +- Dashboard analytics +- Custom reports +- Data exports +- Performance metrics + +Routes: +- Vendor: /api/v1/vendor/analytics/* +- (Admin uses dashboard for analytics) + +Menu Items: +- Admin: (uses dashboard) +- Vendor: analytics +""" + +from app.modules.analytics.definition import analytics_module + +__all__ = ["analytics_module"] diff --git a/app/modules/analytics/definition.py b/app/modules/analytics/definition.py new file mode 100644 index 00000000..42e0c1ff --- /dev/null +++ b/app/modules/analytics/definition.py @@ -0,0 +1,54 @@ +# app/modules/analytics/definition.py +""" +Analytics module definition. + +Defines the analytics module including its features, menu items, +and route configurations. +""" + +from app.modules.base import ModuleDefinition +from models.database.admin_menu_config import FrontendType + + +def _get_vendor_router(): + """Lazy import of vendor router to avoid circular imports.""" + from app.modules.analytics.routes.vendor import vendor_router + + return vendor_router + + +# Analytics module definition +analytics_module = ModuleDefinition( + code="analytics", + name="Analytics & Reporting", + description="Dashboard analytics, custom reports, and data exports.", + features=[ + "basic_reports", # Basic reporting + "analytics_dashboard", # Analytics dashboard + "custom_reports", # Custom report builder + "export_reports", # Export to CSV/Excel + ], + menu_items={ + FrontendType.ADMIN: [ + # Analytics appears in dashboard for admin + ], + FrontendType.VENDOR: [ + "analytics", # Vendor analytics page + ], + }, + is_core=False, +) + + +def get_analytics_module_with_routers() -> ModuleDefinition: + """ + Get analytics module with routers attached. + + This function attaches the routers lazily to avoid circular imports + during module initialization. + """ + analytics_module.vendor_router = _get_vendor_router() + return analytics_module + + +__all__ = ["analytics_module", "get_analytics_module_with_routers"] diff --git a/app/modules/analytics/routes/__init__.py b/app/modules/analytics/routes/__init__.py new file mode 100644 index 00000000..e48ed072 --- /dev/null +++ b/app/modules/analytics/routes/__init__.py @@ -0,0 +1,26 @@ +# app/modules/analytics/routes/__init__.py +""" +Analytics module route registration. + +This module provides functions to register analytics routes +with module-based access control. + +NOTE: Routers are NOT auto-imported to avoid circular dependencies. +Import directly from vendor.py as needed: + from app.modules.analytics.routes.vendor import vendor_router + +Note: Analytics module has no admin routes - admin uses dashboard. +""" + +# Routers are imported on-demand to avoid circular dependencies +# Do NOT add auto-imports here + +__all__ = ["vendor_router"] + + +def __getattr__(name: str): + """Lazy import routers to avoid circular dependencies.""" + if name == "vendor_router": + from app.modules.analytics.routes.vendor import vendor_router + return vendor_router + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/app/modules/analytics/routes/vendor.py b/app/modules/analytics/routes/vendor.py new file mode 100644 index 00000000..68363a00 --- /dev/null +++ b/app/modules/analytics/routes/vendor.py @@ -0,0 +1,25 @@ +# app/modules/analytics/routes/vendor.py +""" +Analytics module vendor routes. + +This module wraps the existing vendor analytics routes and adds +module-based access control. Routes are re-exported from the +original location with the module access dependency. +""" + +from fastapi import APIRouter, Depends + +from app.api.deps import require_module_access + +# Import original router (direct import to avoid circular dependency) +from app.api.v1.vendor.analytics import router as original_router + +# Create module-aware router +vendor_router = APIRouter( + prefix="/analytics", + dependencies=[Depends(require_module_access("analytics"))], +) + +# Re-export all routes from the original module with module access control +for route in original_router.routes: + vendor_router.routes.append(route) diff --git a/app/modules/billing/routes/__init__.py b/app/modules/billing/routes/__init__.py index e1fd8707..048f994a 100644 --- a/app/modules/billing/routes/__init__.py +++ b/app/modules/billing/routes/__init__.py @@ -4,9 +4,25 @@ Billing module route registration. This module provides functions to register billing routes with module-based access control. + +NOTE: Routers are NOT auto-imported to avoid circular dependencies. +Import directly from admin.py or vendor.py as needed: + from app.modules.billing.routes.admin import admin_router + from app.modules.billing.routes.vendor import vendor_router """ -from app.modules.billing.routes.admin import admin_router -from app.modules.billing.routes.vendor import vendor_router +# Routers are imported on-demand to avoid circular dependencies +# Do NOT add auto-imports here __all__ = ["admin_router", "vendor_router"] + + +def __getattr__(name: str): + """Lazy import routers to avoid circular dependencies.""" + if name == "admin_router": + from app.modules.billing.routes.admin import admin_router + return admin_router + elif name == "vendor_router": + from app.modules.billing.routes.vendor import vendor_router + return vendor_router + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/app/modules/cms/__init__.py b/app/modules/cms/__init__.py new file mode 100644 index 00000000..39eb2583 --- /dev/null +++ b/app/modules/cms/__init__.py @@ -0,0 +1,22 @@ +# app/modules/cms/__init__.py +""" +CMS Module - Content Management System. + +This module provides: +- Content pages management +- Media library +- Vendor themes +- SEO tools + +Routes: +- Admin: /api/v1/admin/content-pages/* +- Vendor: /api/v1/vendor/content-pages/*, /api/v1/vendor/media/* + +Menu Items: +- Admin: content-pages, vendor-themes +- Vendor: content-pages, media +""" + +from app.modules.cms.definition import cms_module + +__all__ = ["cms_module"] diff --git a/app/modules/cms/definition.py b/app/modules/cms/definition.py new file mode 100644 index 00000000..8fef7877 --- /dev/null +++ b/app/modules/cms/definition.py @@ -0,0 +1,66 @@ +# app/modules/cms/definition.py +""" +CMS module definition. + +Defines the CMS module including its features, menu items, +and route configurations. +""" + +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.cms.routes.admin import admin_router + + return admin_router + + +def _get_vendor_router(): + """Lazy import of vendor router to avoid circular imports.""" + from app.modules.cms.routes.vendor import vendor_router + + return vendor_router + + +# CMS module definition +cms_module = ModuleDefinition( + code="cms", + name="Content Management", + description="Content pages, media library, and vendor themes.", + features=[ + "cms_basic", # Basic page editing + "cms_custom_pages", # Custom page creation + "cms_unlimited_pages", # No page limit + "cms_templates", # Page templates + "cms_seo", # SEO tools + "media_library", # Media file management + ], + menu_items={ + FrontendType.ADMIN: [ + "content-pages", # Platform content pages + "vendor-themes", # Theme management + ], + FrontendType.VENDOR: [ + "content-pages", # Vendor content pages + "media", # Media library + ], + }, + is_core=False, +) + + +def get_cms_module_with_routers() -> ModuleDefinition: + """ + Get CMS module with routers attached. + + This function attaches the routers lazily to avoid circular imports + during module initialization. + """ + cms_module.admin_router = _get_admin_router() + cms_module.vendor_router = _get_vendor_router() + return cms_module + + +__all__ = ["cms_module", "get_cms_module_with_routers"] diff --git a/app/modules/cms/routes/__init__.py b/app/modules/cms/routes/__init__.py new file mode 100644 index 00000000..15a2f6cc --- /dev/null +++ b/app/modules/cms/routes/__init__.py @@ -0,0 +1,31 @@ +# app/modules/cms/routes/__init__.py +""" +CMS module route registration. + +This module provides functions to register CMS routes +with module-based access control. + +NOTE: Routers are NOT auto-imported to avoid circular dependencies. +Import directly from admin.py or vendor.py as needed: + from app.modules.cms.routes.admin import admin_router + from app.modules.cms.routes.vendor import vendor_router, vendor_media_router +""" + +# Routers are imported on-demand to avoid circular dependencies +# Do NOT add auto-imports here + +__all__ = ["admin_router", "vendor_router", "vendor_media_router"] + + +def __getattr__(name: str): + """Lazy import routers to avoid circular dependencies.""" + if name == "admin_router": + from app.modules.cms.routes.admin import admin_router + return admin_router + elif name == "vendor_router": + from app.modules.cms.routes.vendor import vendor_router + return vendor_router + elif name == "vendor_media_router": + from app.modules.cms.routes.vendor import vendor_media_router + return vendor_media_router + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/app/modules/cms/routes/admin.py b/app/modules/cms/routes/admin.py new file mode 100644 index 00000000..87a80614 --- /dev/null +++ b/app/modules/cms/routes/admin.py @@ -0,0 +1,25 @@ +# app/modules/cms/routes/admin.py +""" +CMS module admin routes. + +This module wraps the existing admin content pages routes and adds +module-based access control. Routes are re-exported from the +original location with the module access dependency. +""" + +from fastapi import APIRouter, Depends + +from app.api.deps import require_module_access + +# Import original router (direct import to avoid circular dependency) +from app.api.v1.admin.content_pages import router as original_router + +# Create module-aware router +admin_router = APIRouter( + prefix="/content-pages", + dependencies=[Depends(require_module_access("cms"))], +) + +# Re-export all routes from the original module with module access control +for route in original_router.routes: + admin_router.routes.append(route) diff --git a/app/modules/cms/routes/vendor.py b/app/modules/cms/routes/vendor.py new file mode 100644 index 00000000..fd7b33d9 --- /dev/null +++ b/app/modules/cms/routes/vendor.py @@ -0,0 +1,39 @@ +# app/modules/cms/routes/vendor.py +""" +CMS module vendor routes. + +This module wraps the existing vendor content pages and media routes +and adds module-based access control. Routes are re-exported from the +original location with the module access dependency. + +Includes: +- /content-pages/* - Content page management +- /media/* - Media library +""" + +from fastapi import APIRouter, Depends + +from app.api.deps import require_module_access + +# Import original routers (direct import to avoid circular dependency) +from app.api.v1.vendor.content_pages import router as content_original_router +from app.api.v1.vendor.media import router as media_original_router + +# Create module-aware router for content pages +vendor_router = APIRouter( + prefix="/content-pages", + dependencies=[Depends(require_module_access("cms"))], +) + +# Re-export all routes from the original content pages module +for route in content_original_router.routes: + vendor_router.routes.append(route) + +# Create separate router for media library +vendor_media_router = APIRouter( + prefix="/media", + dependencies=[Depends(require_module_access("cms"))], +) + +for route in media_original_router.routes: + vendor_media_router.routes.append(route) diff --git a/app/modules/customers/__init__.py b/app/modules/customers/__init__.py new file mode 100644 index 00000000..3a1c129f --- /dev/null +++ b/app/modules/customers/__init__.py @@ -0,0 +1,22 @@ +# app/modules/customers/__init__.py +""" +Customers Module - Customer database and management. + +This module provides: +- Customer profiles and contact information +- Customer segmentation and tags +- Purchase history tracking +- Customer exports + +Routes: +- Admin: /api/v1/admin/customers/* +- Vendor: /api/v1/vendor/customers/* + +Menu Items: +- Admin: customers +- Vendor: customers +""" + +from app.modules.customers.definition import customers_module + +__all__ = ["customers_module"] diff --git a/app/modules/customers/definition.py b/app/modules/customers/definition.py new file mode 100644 index 00000000..e20517f6 --- /dev/null +++ b/app/modules/customers/definition.py @@ -0,0 +1,62 @@ +# app/modules/customers/definition.py +""" +Customers module definition. + +Defines the customers module including its features, menu items, +and route configurations. +""" + +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.customers.routes.admin import admin_router + + return admin_router + + +def _get_vendor_router(): + """Lazy import of vendor router to avoid circular imports.""" + from app.modules.customers.routes.vendor import vendor_router + + return vendor_router + + +# Customers module definition +customers_module = ModuleDefinition( + code="customers", + name="Customer Management", + description="Customer database, profiles, and segmentation.", + features=[ + "customer_view", # View customer profiles + "customer_export", # Export customer data + "customer_profiles", # Detailed customer profiles + "customer_segmentation", # Customer tagging and segments + ], + menu_items={ + FrontendType.ADMIN: [ + "customers", # Platform-wide customer view + ], + FrontendType.VENDOR: [ + "customers", # Vendor customer list + ], + }, + is_core=False, +) + + +def get_customers_module_with_routers() -> ModuleDefinition: + """ + Get customers module with routers attached. + + This function attaches the routers lazily to avoid circular imports + during module initialization. + """ + customers_module.admin_router = _get_admin_router() + customers_module.vendor_router = _get_vendor_router() + return customers_module + + +__all__ = ["customers_module", "get_customers_module_with_routers"] diff --git a/app/modules/customers/routes/__init__.py b/app/modules/customers/routes/__init__.py new file mode 100644 index 00000000..0ea61c78 --- /dev/null +++ b/app/modules/customers/routes/__init__.py @@ -0,0 +1,28 @@ +# app/modules/customers/routes/__init__.py +""" +Customers module route registration. + +This module provides functions to register customers routes +with module-based access control. + +NOTE: Routers are NOT auto-imported to avoid circular dependencies. +Import directly from admin.py or vendor.py as needed: + from app.modules.customers.routes.admin import admin_router + from app.modules.customers.routes.vendor import vendor_router +""" + +# Routers are imported on-demand to avoid circular dependencies +# Do NOT add auto-imports here + +__all__ = ["admin_router", "vendor_router"] + + +def __getattr__(name: str): + """Lazy import routers to avoid circular dependencies.""" + if name == "admin_router": + from app.modules.customers.routes.admin import admin_router + return admin_router + elif name == "vendor_router": + from app.modules.customers.routes.vendor import vendor_router + return vendor_router + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/app/modules/customers/routes/admin.py b/app/modules/customers/routes/admin.py new file mode 100644 index 00000000..ed3accfe --- /dev/null +++ b/app/modules/customers/routes/admin.py @@ -0,0 +1,25 @@ +# app/modules/customers/routes/admin.py +""" +Customers module admin routes. + +This module wraps the existing admin customers routes and adds +module-based access control. Routes are re-exported from the +original location with the module access dependency. +""" + +from fastapi import APIRouter, Depends + +from app.api.deps import require_module_access + +# Import original router (direct import to avoid circular dependency) +from app.api.v1.admin.customers import router as original_router + +# Create module-aware router +admin_router = APIRouter( + prefix="/customers", + dependencies=[Depends(require_module_access("customers"))], +) + +# Re-export all routes from the original module with module access control +for route in original_router.routes: + admin_router.routes.append(route) diff --git a/app/modules/customers/routes/vendor.py b/app/modules/customers/routes/vendor.py new file mode 100644 index 00000000..a7a28bf6 --- /dev/null +++ b/app/modules/customers/routes/vendor.py @@ -0,0 +1,25 @@ +# app/modules/customers/routes/vendor.py +""" +Customers module vendor routes. + +This module wraps the existing vendor customers routes and adds +module-based access control. Routes are re-exported from the +original location with the module access dependency. +""" + +from fastapi import APIRouter, Depends + +from app.api.deps import require_module_access + +# Import original router (direct import to avoid circular dependency) +from app.api.v1.vendor.customers import router as original_router + +# Create module-aware router +vendor_router = APIRouter( + prefix="/customers", + dependencies=[Depends(require_module_access("customers"))], +) + +# Re-export all routes from the original module with module access control +for route in original_router.routes: + vendor_router.routes.append(route) diff --git a/app/modules/dev_tools/__init__.py b/app/modules/dev_tools/__init__.py new file mode 100644 index 00000000..5416fcd5 --- /dev/null +++ b/app/modules/dev_tools/__init__.py @@ -0,0 +1,21 @@ +# app/modules/dev_tools/__init__.py +""" +Dev-Tools Module - Developer tools and utilities. + +This module provides: +- Component library browser +- Icon browser +- Development utilities + +Routes: +- Admin: (page routes only, minimal API) +- Vendor: None + +Menu Items: +- Admin: components, icons +- Vendor: None +""" + +from app.modules.dev_tools.definition import dev_tools_module + +__all__ = ["dev_tools_module"] diff --git a/app/modules/dev_tools/definition.py b/app/modules/dev_tools/definition.py new file mode 100644 index 00000000..947c98e1 --- /dev/null +++ b/app/modules/dev_tools/definition.py @@ -0,0 +1,35 @@ +# app/modules/dev_tools/definition.py +""" +Dev-Tools module definition. + +Defines the dev-tools module including its features, menu items, +and route configurations. + +Note: This module primarily provides page routes, not API routes. +""" + +from app.modules.base import ModuleDefinition +from models.database.admin_menu_config import FrontendType + + +# Dev-Tools module definition +dev_tools_module = ModuleDefinition( + code="dev-tools", + name="Developer Tools", + description="Component library and icon browser for development.", + features=[ + "component_library", # UI component browser + "icon_browser", # Icon library browser + ], + menu_items={ + FrontendType.ADMIN: [ + "components", # Component library page + "icons", # Icon browser page + ], + FrontendType.VENDOR: [], # No vendor menu items + }, + is_core=False, +) + + +__all__ = ["dev_tools_module"] diff --git a/app/modules/dev_tools/routes/__init__.py b/app/modules/dev_tools/routes/__init__.py new file mode 100644 index 00000000..4efc3704 --- /dev/null +++ b/app/modules/dev_tools/routes/__init__.py @@ -0,0 +1,15 @@ +# app/modules/dev_tools/routes/__init__.py +""" +Dev-Tools module route registration. + +This module provides functions to register dev-tools routes +with module-based access control. + +Note: Dev-Tools module has primarily page routes, not API routes. +The page routes are defined in admin/vendor page handlers. +""" + +# Dev-tools has minimal API routes - primarily page routes +# No auto-imports needed + +__all__ = [] diff --git a/app/modules/inventory/routes/__init__.py b/app/modules/inventory/routes/__init__.py index 7d473e6c..f1ecd121 100644 --- a/app/modules/inventory/routes/__init__.py +++ b/app/modules/inventory/routes/__init__.py @@ -4,9 +4,25 @@ Inventory module route registration. This module provides functions to register inventory routes with module-based access control. + +NOTE: Routers are NOT auto-imported to avoid circular dependencies. +Import directly from admin.py or vendor.py as needed: + from app.modules.inventory.routes.admin import admin_router + from app.modules.inventory.routes.vendor import vendor_router """ -from app.modules.inventory.routes.admin import admin_router -from app.modules.inventory.routes.vendor import vendor_router +# Routers are imported on-demand to avoid circular dependencies +# Do NOT add auto-imports here __all__ = ["admin_router", "vendor_router"] + + +def __getattr__(name: str): + """Lazy import routers to avoid circular dependencies.""" + if name == "admin_router": + from app.modules.inventory.routes.admin import admin_router + return admin_router + elif name == "vendor_router": + from app.modules.inventory.routes.vendor import vendor_router + return vendor_router + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/app/modules/inventory/routes/admin.py b/app/modules/inventory/routes/admin.py index 0e78cc5f..999b9050 100644 --- a/app/modules/inventory/routes/admin.py +++ b/app/modules/inventory/routes/admin.py @@ -11,8 +11,8 @@ from fastapi import APIRouter, Depends from app.api.deps import require_module_access -# Import original router -from app.api.v1.admin import inventory as inventory_routes +# Import original router (direct import to avoid circular dependency) +from app.api.v1.admin.inventory import router as original_router # Create module-aware router admin_router = APIRouter( @@ -22,5 +22,5 @@ admin_router = APIRouter( # Re-export all routes from the original module with module access control # The routes are copied to maintain the same API structure -for route in inventory_routes.router.routes: +for route in original_router.routes: admin_router.routes.append(route) diff --git a/app/modules/inventory/routes/vendor.py b/app/modules/inventory/routes/vendor.py index f916637b..3c9d19b7 100644 --- a/app/modules/inventory/routes/vendor.py +++ b/app/modules/inventory/routes/vendor.py @@ -11,8 +11,8 @@ from fastapi import APIRouter, Depends from app.api.deps import require_module_access -# Import original router -from app.api.v1.vendor import inventory as inventory_routes +# Import original router (direct import to avoid circular dependency) +from app.api.v1.vendor.inventory import router as original_router # Create module-aware router vendor_router = APIRouter( @@ -21,5 +21,5 @@ vendor_router = APIRouter( ) # Re-export all routes from the original module with module access control -for route in inventory_routes.router.routes: +for route in original_router.routes: vendor_router.routes.append(route) diff --git a/app/modules/marketplace/routes/__init__.py b/app/modules/marketplace/routes/__init__.py index 2831a52a..a014b302 100644 --- a/app/modules/marketplace/routes/__init__.py +++ b/app/modules/marketplace/routes/__init__.py @@ -4,9 +4,31 @@ Marketplace module route registration. This module provides functions to register marketplace routes with module-based access control. + +NOTE: Routers are NOT auto-imported to avoid circular dependencies. +Import directly from admin.py or vendor.py as needed: + from app.modules.marketplace.routes.admin import admin_router, admin_letzshop_router + from app.modules.marketplace.routes.vendor import vendor_router, vendor_letzshop_router """ -from app.modules.marketplace.routes.admin import admin_router, admin_letzshop_router -from app.modules.marketplace.routes.vendor import vendor_router, vendor_letzshop_router +# Routers are imported on-demand to avoid circular dependencies +# Do NOT add auto-imports here __all__ = ["admin_router", "admin_letzshop_router", "vendor_router", "vendor_letzshop_router"] + + +def __getattr__(name: str): + """Lazy import routers to avoid circular dependencies.""" + if name == "admin_router": + from app.modules.marketplace.routes.admin import admin_router + return admin_router + elif name == "admin_letzshop_router": + from app.modules.marketplace.routes.admin import admin_letzshop_router + return admin_letzshop_router + elif name == "vendor_router": + from app.modules.marketplace.routes.vendor import vendor_router + return vendor_router + elif name == "vendor_letzshop_router": + from app.modules.marketplace.routes.vendor import vendor_letzshop_router + return vendor_letzshop_router + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/app/modules/marketplace/routes/admin.py b/app/modules/marketplace/routes/admin.py index c8e474a0..249b7314 100644 --- a/app/modules/marketplace/routes/admin.py +++ b/app/modules/marketplace/routes/admin.py @@ -15,9 +15,9 @@ from fastapi import APIRouter, Depends from app.api.deps import require_module_access -# Import original routers -from app.api.v1.admin import marketplace as marketplace_routes -from app.api.v1.admin import letzshop as letzshop_routes +# Import original routers (direct import to avoid circular dependency) +from app.api.v1.admin.marketplace import router as marketplace_original_router +from app.api.v1.admin.letzshop import router as letzshop_original_router # Create module-aware router for marketplace admin_router = APIRouter( @@ -26,7 +26,7 @@ admin_router = APIRouter( ) # Re-export all routes from the original marketplace module -for route in marketplace_routes.router.routes: +for route in marketplace_original_router.routes: admin_router.routes.append(route) # Create separate router for letzshop integration @@ -35,5 +35,5 @@ admin_letzshop_router = APIRouter( dependencies=[Depends(require_module_access("marketplace"))], ) -for route in letzshop_routes.router.routes: +for route in letzshop_original_router.routes: admin_letzshop_router.routes.append(route) diff --git a/app/modules/marketplace/routes/vendor.py b/app/modules/marketplace/routes/vendor.py index bbbe34a2..336e577f 100644 --- a/app/modules/marketplace/routes/vendor.py +++ b/app/modules/marketplace/routes/vendor.py @@ -15,9 +15,9 @@ from fastapi import APIRouter, Depends from app.api.deps import require_module_access -# Import original routers -from app.api.v1.vendor import marketplace as marketplace_routes -from app.api.v1.vendor import letzshop as letzshop_routes +# Import original routers (direct import to avoid circular dependency) +from app.api.v1.vendor.marketplace import router as marketplace_original_router +from app.api.v1.vendor.letzshop import router as letzshop_original_router # Create module-aware router for marketplace vendor_router = APIRouter( @@ -26,7 +26,7 @@ vendor_router = APIRouter( ) # Re-export all routes from the original marketplace module -for route in marketplace_routes.router.routes: +for route in marketplace_original_router.routes: vendor_router.routes.append(route) # Create separate router for letzshop integration @@ -35,5 +35,5 @@ vendor_letzshop_router = APIRouter( dependencies=[Depends(require_module_access("marketplace"))], ) -for route in letzshop_routes.router.routes: +for route in letzshop_original_router.routes: vendor_letzshop_router.routes.append(route) diff --git a/app/modules/messaging/__init__.py b/app/modules/messaging/__init__.py new file mode 100644 index 00000000..7310384c --- /dev/null +++ b/app/modules/messaging/__init__.py @@ -0,0 +1,22 @@ +# app/modules/messaging/__init__.py +""" +Messaging Module - Internal messaging and notifications. + +This module provides: +- Internal messages between users +- Customer communication +- Notification center +- Email notifications + +Routes: +- Admin: /api/v1/admin/messages/*, /api/v1/admin/notifications/* +- Vendor: /api/v1/vendor/messages/*, /api/v1/vendor/notifications/* + +Menu Items: +- Admin: messages, notifications +- Vendor: messages, notifications +""" + +from app.modules.messaging.definition import messaging_module + +__all__ = ["messaging_module"] diff --git a/app/modules/messaging/definition.py b/app/modules/messaging/definition.py new file mode 100644 index 00000000..8189c6e9 --- /dev/null +++ b/app/modules/messaging/definition.py @@ -0,0 +1,77 @@ +# app/modules/messaging/definition.py +""" +Messaging module definition. + +Defines the messaging module including its features, menu items, +and route configurations. +""" + +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.messaging.routes.admin import admin_router + + return admin_router + + +def _get_admin_notifications_router(): + """Lazy import of admin notifications router to avoid circular imports.""" + from app.modules.messaging.routes.admin import admin_notifications_router + + return admin_notifications_router + + +def _get_vendor_router(): + """Lazy import of vendor router to avoid circular imports.""" + from app.modules.messaging.routes.vendor import vendor_router + + return vendor_router + + +def _get_vendor_notifications_router(): + """Lazy import of vendor notifications router to avoid circular imports.""" + from app.modules.messaging.routes.vendor import vendor_notifications_router + + return vendor_notifications_router + + +# Messaging module definition +messaging_module = ModuleDefinition( + code="messaging", + name="Messaging & Notifications", + description="Internal messages, customer communication, and notifications.", + features=[ + "customer_messaging", # Customer communication + "internal_messages", # Internal team messages + "notification_center", # Notification management + ], + menu_items={ + FrontendType.ADMIN: [ + "messages", # Admin messages + "notifications", # Admin notifications + ], + FrontendType.VENDOR: [ + "messages", # Vendor messages + "notifications", # Vendor notifications + ], + }, + is_core=False, +) + + +def get_messaging_module_with_routers() -> ModuleDefinition: + """ + Get messaging module with routers attached. + + This function attaches the routers lazily to avoid circular imports + during module initialization. + """ + messaging_module.admin_router = _get_admin_router() + messaging_module.vendor_router = _get_vendor_router() + return messaging_module + + +__all__ = ["messaging_module", "get_messaging_module_with_routers"] diff --git a/app/modules/messaging/routes/__init__.py b/app/modules/messaging/routes/__init__.py new file mode 100644 index 00000000..5d6e2521 --- /dev/null +++ b/app/modules/messaging/routes/__init__.py @@ -0,0 +1,34 @@ +# app/modules/messaging/routes/__init__.py +""" +Messaging module route registration. + +This module provides functions to register messaging routes +with module-based access control. + +NOTE: Routers are NOT auto-imported to avoid circular dependencies. +Import directly from admin.py or vendor.py as needed: + from app.modules.messaging.routes.admin import admin_router, admin_notifications_router + from app.modules.messaging.routes.vendor import vendor_router, vendor_notifications_router +""" + +# Routers are imported on-demand to avoid circular dependencies +# Do NOT add auto-imports here + +__all__ = ["admin_router", "admin_notifications_router", "vendor_router", "vendor_notifications_router"] + + +def __getattr__(name: str): + """Lazy import routers to avoid circular dependencies.""" + if name == "admin_router": + from app.modules.messaging.routes.admin import admin_router + return admin_router + elif name == "admin_notifications_router": + from app.modules.messaging.routes.admin import admin_notifications_router + return admin_notifications_router + elif name == "vendor_router": + from app.modules.messaging.routes.vendor import vendor_router + return vendor_router + elif name == "vendor_notifications_router": + from app.modules.messaging.routes.vendor import vendor_notifications_router + return vendor_notifications_router + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/app/modules/messaging/routes/admin.py b/app/modules/messaging/routes/admin.py new file mode 100644 index 00000000..8e4f15df --- /dev/null +++ b/app/modules/messaging/routes/admin.py @@ -0,0 +1,39 @@ +# app/modules/messaging/routes/admin.py +""" +Messaging module admin routes. + +This module wraps the existing admin messages and notifications routes +and adds module-based access control. Routes are re-exported from the +original location with the module access dependency. + +Includes: +- /messages/* - Message management +- /notifications/* - Notification management +""" + +from fastapi import APIRouter, Depends + +from app.api.deps import require_module_access + +# Import original routers (direct import to avoid circular dependency) +from app.api.v1.admin.messages import router as messages_original_router +from app.api.v1.admin.notifications import router as notifications_original_router + +# Create module-aware router for messages +admin_router = APIRouter( + prefix="/messages", + dependencies=[Depends(require_module_access("messaging"))], +) + +# Re-export all routes from the original messages module +for route in messages_original_router.routes: + admin_router.routes.append(route) + +# Create separate router for notifications +admin_notifications_router = APIRouter( + prefix="/notifications", + dependencies=[Depends(require_module_access("messaging"))], +) + +for route in notifications_original_router.routes: + admin_notifications_router.routes.append(route) diff --git a/app/modules/messaging/routes/vendor.py b/app/modules/messaging/routes/vendor.py new file mode 100644 index 00000000..9766b24d --- /dev/null +++ b/app/modules/messaging/routes/vendor.py @@ -0,0 +1,39 @@ +# app/modules/messaging/routes/vendor.py +""" +Messaging module vendor routes. + +This module wraps the existing vendor messages and notifications routes +and adds module-based access control. Routes are re-exported from the +original location with the module access dependency. + +Includes: +- /messages/* - Message management +- /notifications/* - Notification management +""" + +from fastapi import APIRouter, Depends + +from app.api.deps import require_module_access + +# Import original routers (direct import to avoid circular dependency) +from app.api.v1.vendor.messages import router as messages_original_router +from app.api.v1.vendor.notifications import router as notifications_original_router + +# Create module-aware router for messages +vendor_router = APIRouter( + prefix="/messages", + dependencies=[Depends(require_module_access("messaging"))], +) + +# Re-export all routes from the original messages module +for route in messages_original_router.routes: + vendor_router.routes.append(route) + +# Create separate router for notifications +vendor_notifications_router = APIRouter( + prefix="/notifications", + dependencies=[Depends(require_module_access("messaging"))], +) + +for route in notifications_original_router.routes: + vendor_notifications_router.routes.append(route) diff --git a/app/modules/monitoring/__init__.py b/app/modules/monitoring/__init__.py new file mode 100644 index 00000000..8fef73bd --- /dev/null +++ b/app/modules/monitoring/__init__.py @@ -0,0 +1,25 @@ +# app/modules/monitoring/__init__.py +""" +Monitoring Module - Platform monitoring and system health. + +This module provides: +- Application logs +- Background tasks monitoring +- Import job tracking +- Platform health metrics +- Testing hub +- Code quality tools + +Routes: +- Admin: /api/v1/admin/logs/*, /api/v1/admin/background-tasks/*, + /api/v1/admin/tests/*, /api/v1/admin/code-quality/* +- Vendor: None + +Menu Items: +- Admin: imports, background-tasks, logs, platform-health, testing, code-quality +- Vendor: None +""" + +from app.modules.monitoring.definition import monitoring_module + +__all__ = ["monitoring_module"] diff --git a/app/modules/monitoring/definition.py b/app/modules/monitoring/definition.py new file mode 100644 index 00000000..cd33f005 --- /dev/null +++ b/app/modules/monitoring/definition.py @@ -0,0 +1,59 @@ +# app/modules/monitoring/definition.py +""" +Monitoring module definition. + +Defines the monitoring module including its features, menu items, +and route configurations. +""" + +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.monitoring.routes.admin import admin_router + + return admin_router + + +# Monitoring module definition +monitoring_module = ModuleDefinition( + code="monitoring", + name="Platform Monitoring", + description="Logs, background tasks, imports, and system health.", + features=[ + "application_logs", # Log viewing + "background_tasks", # Task monitoring + "import_jobs", # Import job tracking + "capacity_monitoring", # System capacity + "testing_hub", # Test runner + "code_quality", # Code quality tools + ], + menu_items={ + FrontendType.ADMIN: [ + "imports", # Import jobs + "background-tasks", # Background tasks + "logs", # Application logs + "platform-health", # Platform health + "testing", # Testing hub + "code-quality", # Code quality + ], + FrontendType.VENDOR: [], # No vendor menu items + }, + is_core=False, +) + + +def get_monitoring_module_with_routers() -> ModuleDefinition: + """ + Get monitoring module with routers attached. + + This function attaches the routers lazily to avoid circular imports + during module initialization. + """ + monitoring_module.admin_router = _get_admin_router() + return monitoring_module + + +__all__ = ["monitoring_module", "get_monitoring_module_with_routers"] diff --git a/app/modules/monitoring/routes/__init__.py b/app/modules/monitoring/routes/__init__.py new file mode 100644 index 00000000..619e0627 --- /dev/null +++ b/app/modules/monitoring/routes/__init__.py @@ -0,0 +1,26 @@ +# app/modules/monitoring/routes/__init__.py +""" +Monitoring module route registration. + +This module provides functions to register monitoring routes +with module-based access control. + +NOTE: Routers are NOT auto-imported to avoid circular dependencies. +Import directly from admin.py as needed: + from app.modules.monitoring.routes.admin import admin_router + +Note: Monitoring module has no vendor routes. +""" + +# Routers are imported on-demand to avoid circular dependencies +# Do NOT add auto-imports here + +__all__ = ["admin_router"] + + +def __getattr__(name: str): + """Lazy import routers to avoid circular dependencies.""" + if name == "admin_router": + from app.modules.monitoring.routes.admin import admin_router + return admin_router + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/app/modules/monitoring/routes/admin.py b/app/modules/monitoring/routes/admin.py new file mode 100644 index 00000000..fadce965 --- /dev/null +++ b/app/modules/monitoring/routes/admin.py @@ -0,0 +1,53 @@ +# app/modules/monitoring/routes/admin.py +""" +Monitoring module admin routes. + +This module wraps the existing admin monitoring routes and adds +module-based access control. Routes are re-exported from the +original location with the module access dependency. + +Includes: +- /logs/* - Application logs +- /background-tasks/* - Background task monitoring +- /tests/* - Test runner +- /code-quality/* - Code quality tools +""" + +from fastapi import APIRouter, Depends + +from app.api.deps import require_module_access + +# Import original routers (direct import to avoid circular dependency) +from app.api.v1.admin.logs import router as logs_original_router +from app.api.v1.admin.background_tasks import router as tasks_original_router +from app.api.v1.admin.tests import router as tests_original_router +from app.api.v1.admin.code_quality import router as code_quality_original_router + +# Create module-aware router for logs +admin_router = APIRouter( + prefix="/monitoring", + dependencies=[Depends(require_module_access("monitoring"))], +) + +# Create sub-routers for each component +logs_router = APIRouter(prefix="/logs") +for route in logs_original_router.routes: + logs_router.routes.append(route) + +tasks_router = APIRouter(prefix="/background-tasks") +for route in tasks_original_router.routes: + tasks_router.routes.append(route) + +tests_router = APIRouter(prefix="/tests") +for route in tests_original_router.routes: + tests_router.routes.append(route) + +code_quality_router = APIRouter(prefix="/code-quality") +for route in code_quality_original_router.routes: + code_quality_router.routes.append(route) + +# Include all sub-routers +admin_router.include_router(logs_router) +admin_router.include_router(tasks_router) +admin_router.include_router(tests_router) +admin_router.include_router(code_quality_router) diff --git a/app/modules/orders/routes/__init__.py b/app/modules/orders/routes/__init__.py index 60f67795..c8e48617 100644 --- a/app/modules/orders/routes/__init__.py +++ b/app/modules/orders/routes/__init__.py @@ -4,9 +4,25 @@ Orders module route registration. This module provides functions to register orders routes with module-based access control. + +NOTE: Routers are NOT auto-imported to avoid circular dependencies. +Import directly from admin.py or vendor.py as needed: + from app.modules.orders.routes.admin import admin_router + from app.modules.orders.routes.vendor import vendor_router """ -from app.modules.orders.routes.admin import admin_router -from app.modules.orders.routes.vendor import vendor_router +# Routers are imported on-demand to avoid circular dependencies +# Do NOT add auto-imports here __all__ = ["admin_router", "vendor_router"] + + +def __getattr__(name: str): + """Lazy import routers to avoid circular dependencies.""" + if name == "admin_router": + from app.modules.orders.routes.admin import admin_router + return admin_router + elif name == "vendor_router": + from app.modules.orders.routes.vendor import vendor_router + return vendor_router + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/app/modules/orders/routes/admin.py b/app/modules/orders/routes/admin.py index a57f0fea..b05618da 100644 --- a/app/modules/orders/routes/admin.py +++ b/app/modules/orders/routes/admin.py @@ -15,9 +15,9 @@ from fastapi import APIRouter, Depends from app.api.deps import require_module_access -# Import original routers -from app.api.v1.admin import orders as orders_routes -from app.api.v1.admin import order_item_exceptions as exceptions_routes +# Import original routers (direct import to avoid circular dependency) +from app.api.v1.admin.orders import router as orders_original_router +from app.api.v1.admin.order_item_exceptions import router as exceptions_original_router # Create module-aware router for orders admin_router = APIRouter( @@ -26,7 +26,7 @@ admin_router = APIRouter( ) # Re-export all routes from the original orders module -for route in orders_routes.router.routes: +for route in orders_original_router.routes: admin_router.routes.append(route) # Create separate router for order item exceptions @@ -36,5 +36,5 @@ admin_exceptions_router = APIRouter( dependencies=[Depends(require_module_access("orders"))], ) -for route in exceptions_routes.router.routes: +for route in exceptions_original_router.routes: admin_exceptions_router.routes.append(route) diff --git a/app/modules/orders/routes/vendor.py b/app/modules/orders/routes/vendor.py index 01d15e67..1d2aed89 100644 --- a/app/modules/orders/routes/vendor.py +++ b/app/modules/orders/routes/vendor.py @@ -15,9 +15,9 @@ from fastapi import APIRouter, Depends from app.api.deps import require_module_access -# Import original routers -from app.api.v1.vendor import orders as orders_routes -from app.api.v1.vendor import order_item_exceptions as exceptions_routes +# Import original routers (direct import to avoid circular dependency) +from app.api.v1.vendor.orders import router as orders_original_router +from app.api.v1.vendor.order_item_exceptions import router as exceptions_original_router # Create module-aware router for orders vendor_router = APIRouter( @@ -26,7 +26,7 @@ vendor_router = APIRouter( ) # Re-export all routes from the original orders module -for route in orders_routes.router.routes: +for route in orders_original_router.routes: vendor_router.routes.append(route) # Create separate router for order item exceptions @@ -35,5 +35,5 @@ vendor_exceptions_router = APIRouter( dependencies=[Depends(require_module_access("orders"))], ) -for route in exceptions_routes.router.routes: +for route in exceptions_original_router.routes: vendor_exceptions_router.routes.append(route) diff --git a/app/modules/registry.py b/app/modules/registry.py index be2552e3..c53827fa 100644 --- a/app/modules/registry.py +++ b/app/modules/registry.py @@ -24,6 +24,12 @@ from app.modules.billing.definition import billing_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 +from app.modules.customers.definition import customers_module +from app.modules.cms.definition import cms_module +from app.modules.analytics.definition import analytics_module +from app.modules.messaging.definition import messaging_module +from app.modules.dev_tools.definition import dev_tools_module +from app.modules.monitoring.definition import monitoring_module # ============================================================================= @@ -93,127 +99,18 @@ MODULES: dict[str, ModuleDefinition] = { "orders": orders_module, # Marketplace module - imported from app/modules/marketplace/ "marketplace": marketplace_module, - "customers": ModuleDefinition( - code="customers", - name="Customer Management", - description="Customer database, profiles, and segmentation.", - features=[ - "customer_view", - "customer_export", - "customer_profiles", - "customer_segmentation", - ], - menu_items={ - FrontendType.ADMIN: [ - "customers", - ], - FrontendType.VENDOR: [ - "customers", - ], - }, - ), - "cms": ModuleDefinition( - code="cms", - name="Content Management", - description="Content pages, media library, and vendor themes.", - features=[ - "cms_basic", - "cms_custom_pages", - "cms_unlimited_pages", - "cms_templates", - "cms_seo", - "media_library", - ], - menu_items={ - FrontendType.ADMIN: [ - "content-pages", - "vendor-themes", - ], - FrontendType.VENDOR: [ - "content-pages", - "media", - ], - }, - ), - "analytics": ModuleDefinition( - code="analytics", - name="Analytics & Reporting", - description="Dashboard analytics, custom reports, and data exports.", - features=[ - "basic_reports", - "analytics_dashboard", - "custom_reports", - "export_reports", - ], - menu_items={ - FrontendType.ADMIN: [ - # Analytics appears in dashboard for admin - ], - FrontendType.VENDOR: [ - "analytics", - ], - }, - ), - "messaging": ModuleDefinition( - code="messaging", - name="Messaging & Notifications", - description="Internal messages, customer communication, and notifications.", - features=[ - "customer_messaging", - "internal_messages", - "notification_center", - ], - menu_items={ - FrontendType.ADMIN: [ - "messages", - "notifications", - ], - FrontendType.VENDOR: [ - "messages", - "notifications", - ], - }, - ), - "dev-tools": ModuleDefinition( - code="dev-tools", - name="Developer Tools", - description="Component library and icon browser for development.", - features=[ - "component_library", - "icon_browser", - ], - menu_items={ - FrontendType.ADMIN: [ - "components", - "icons", - ], - FrontendType.VENDOR: [], - }, - ), - "monitoring": ModuleDefinition( - code="monitoring", - name="Platform Monitoring", - description="Logs, background tasks, imports, and system health.", - features=[ - "application_logs", - "background_tasks", - "import_jobs", - "capacity_monitoring", - "testing_hub", - "code_quality", - ], - menu_items={ - FrontendType.ADMIN: [ - "imports", - "background-tasks", - "logs", - "platform-health", - "testing", - "code-quality", - ], - FrontendType.VENDOR: [], - }, - ), + # 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, + # Dev-Tools module - imported from app/modules/dev_tools/ + "dev-tools": dev_tools_module, + # Monitoring module - imported from app/modules/monitoring/ + "monitoring": monitoring_module, } diff --git a/app/modules/service.py b/app/modules/service.py index 65c1c172..d0405ff0 100644 --- a/app/modules/service.py +++ b/app/modules/service.py @@ -5,11 +5,15 @@ Module service for platform module operations. Provides methods to check module enablement, get enabled modules, and filter menu items based on module configuration. -Module configuration is stored in Platform.settings["enabled_modules"]. -If not configured, all modules are enabled (backwards compatibility). +Module configuration can be stored in two places: +1. PlatformModule junction table (preferred, auditable) +2. Platform.settings["enabled_modules"] (fallback, legacy) + +If neither is configured, all modules are enabled (backwards compatibility). """ import logging +from datetime import datetime, timezone from functools import lru_cache from sqlalchemy.orm import Session @@ -23,6 +27,7 @@ from app.modules.registry import ( ) from models.database.admin_menu_config import FrontendType from models.database.platform import Platform +from models.database.platform_module import PlatformModule logger = logging.getLogger(__name__) @@ -34,11 +39,20 @@ class ModuleService: Handles module enablement checking, module listing, and menu item filtering based on enabled modules. - Module configuration is stored in Platform.settings["enabled_modules"]: - - If key exists: Only listed modules (plus core) are enabled - - If key missing: All modules are enabled (backwards compatibility) + Module configuration is stored in two places (with fallback): + 1. PlatformModule junction table (preferred, auditable) + 2. Platform.settings["enabled_modules"] (legacy fallback) - Example Platform.settings: + The service checks the junction table first. If no records exist, + it falls back to the JSON settings for backwards compatibility. + + If neither is configured, all modules are enabled (backwards compatibility). + + Example PlatformModule records: + PlatformModule(platform_id=1, module_code="billing", is_enabled=True, config={"stripe_mode": "live"}) + PlatformModule(platform_id=1, module_code="inventory", is_enabled=True, config={"low_stock_threshold": 10}) + + Legacy Platform.settings (fallback): { "enabled_modules": ["core", "billing", "inventory", "orders"], "module_config": { @@ -119,10 +133,13 @@ class ModuleService: platform_id: int, ) -> set[str]: """ - Get enabled module codes from platform settings. + Get enabled module codes for a platform. - Internal method that reads Platform.settings["enabled_modules"]. - If not configured, returns all module codes (backwards compatibility). + Checks two sources with fallback: + 1. PlatformModule junction table (preferred, auditable) + 2. Platform.settings["enabled_modules"] (legacy fallback) + + If neither is configured, returns all module codes (backwards compatibility). Always includes core modules. Args: @@ -137,22 +154,102 @@ class ModuleService: logger.warning(f"Platform {platform_id} not found, returning all modules") return set(MODULES.keys()) - settings = platform.settings or {} - enabled_modules = settings.get("enabled_modules") + # Try junction table first (preferred) + platform_modules = ( + db.query(PlatformModule) + .filter(PlatformModule.platform_id == platform_id) + .all() + ) - # If not configured, enable all modules (backwards compatibility) - if enabled_modules is None: - return set(MODULES.keys()) + if platform_modules: + # Use junction table data + enabled_set = {pm.module_code for pm in platform_modules if pm.is_enabled} + else: + # Fallback to JSON settings (legacy) + settings = platform.settings or {} + enabled_modules = settings.get("enabled_modules") + + # If not configured, enable all modules (backwards compatibility) + if enabled_modules is None: + return set(MODULES.keys()) + + enabled_set = set(enabled_modules) # Always include core modules core_codes = get_core_module_codes() - enabled_set = set(enabled_modules) | core_codes + enabled_set = enabled_set | core_codes # Resolve dependencies - add required modules enabled_set = self._resolve_dependencies(enabled_set) return enabled_set + def _migrate_json_to_junction_table( + self, + db: Session, + platform_id: int, + user_id: int | None = None, + ) -> None: + """ + Migrate JSON settings to junction table records. + + Called when first creating a junction table record for a platform + that previously used JSON settings. This ensures consistency when + mixing junction table and JSON approaches. + + Args: + db: Database session + platform_id: Platform ID + user_id: ID of user performing the migration (for audit) + """ + # Check if any junction table records exist + existing_count = ( + db.query(PlatformModule) + .filter(PlatformModule.platform_id == platform_id) + .count() + ) + + if existing_count > 0: + # Already using junction table + return + + platform = db.query(Platform).filter(Platform.id == platform_id).first() + if not platform: + return + + settings = platform.settings or {} + enabled_modules = settings.get("enabled_modules") + + if enabled_modules is None: + # No JSON settings, start fresh with all modules enabled + enabled_codes = set(MODULES.keys()) + else: + enabled_codes = set(enabled_modules) | get_core_module_codes() + + now = datetime.now(timezone.utc) + + # Create junction table records for all known modules + for code in MODULES.keys(): + is_enabled = code in enabled_codes + pm = PlatformModule( + platform_id=platform_id, + module_code=code, + is_enabled=is_enabled, + enabled_at=now if is_enabled else None, + enabled_by_user_id=user_id if is_enabled else None, + disabled_at=None if is_enabled else now, + disabled_by_user_id=None if is_enabled else user_id, + config={}, + ) + db.add(pm) + + # Flush to ensure records are visible to subsequent queries + db.flush() + + logger.info( + f"Migrated platform {platform_id} from JSON settings to junction table" + ) + def _resolve_dependencies(self, enabled_codes: set[str]) -> set[str]: """ Resolve module dependencies by adding required modules. @@ -283,6 +380,10 @@ class ModuleService: """ Get module-specific configuration for a platform. + Checks two sources with fallback: + 1. PlatformModule.config (preferred, auditable) + 2. Platform.settings["module_config"] (legacy fallback) + Args: db: Database session platform_id: Platform ID @@ -291,6 +392,20 @@ class ModuleService: Returns: Module configuration dict (empty if not configured) """ + # Try junction table first (preferred) + platform_module = ( + db.query(PlatformModule) + .filter( + PlatformModule.platform_id == platform_id, + PlatformModule.module_code == module_code, + ) + .first() + ) + + if platform_module: + return platform_module.config or {} + + # Fallback to JSON settings (legacy) platform = db.query(Platform).filter(Platform.id == platform_id).first() if not platform: return {} @@ -299,22 +414,80 @@ class ModuleService: module_configs = settings.get("module_config", {}) return module_configs.get(module_code, {}) + def set_module_config( + self, + db: Session, + platform_id: int, + module_code: str, + config: dict, + ) -> bool: + """ + Set module-specific configuration for a platform. + + Uses junction table for persistence. Creates record if doesn't exist. + + Args: + db: Database session + platform_id: Platform ID + module_code: Module code + config: Configuration dict to set + + Returns: + True if successful + """ + if module_code not in MODULES: + logger.error(f"Unknown module: {module_code}") + return False + + platform = db.query(Platform).filter(Platform.id == platform_id).first() + if not platform: + logger.error(f"Platform {platform_id} not found") + return False + + # Get or create junction table record + platform_module = ( + db.query(PlatformModule) + .filter( + PlatformModule.platform_id == platform_id, + PlatformModule.module_code == module_code, + ) + .first() + ) + + if platform_module: + platform_module.config = config + else: + # Create new record with config + platform_module = PlatformModule( + platform_id=platform_id, + module_code=module_code, + is_enabled=True, # Default to enabled + config=config, + ) + db.add(platform_module) + + logger.info(f"Updated config for module '{module_code}' on platform {platform_id}") + return True + def set_enabled_modules( self, db: Session, platform_id: int, module_codes: list[str], + user_id: int | None = None, ) -> bool: """ Set the enabled modules for a platform. Core modules are automatically included. Dependencies are automatically resolved. + Uses junction table for auditability. Args: db: Database session platform_id: Platform ID module_codes: List of module codes to enable + user_id: ID of user making the change (for audit) Returns: True if successful, False if platform not found @@ -338,10 +511,44 @@ class ModuleService: # Resolve dependencies enabled_set = self._resolve_dependencies(enabled_set) - # Update platform settings - settings = platform.settings or {} - settings["enabled_modules"] = list(enabled_set) - platform.settings = settings + now = datetime.now(timezone.utc) + + # Update junction table for all modules + for code in MODULES.keys(): + platform_module = ( + db.query(PlatformModule) + .filter( + PlatformModule.platform_id == platform_id, + PlatformModule.module_code == code, + ) + .first() + ) + + should_enable = code in enabled_set + + if platform_module: + # Update existing record + if should_enable and not platform_module.is_enabled: + platform_module.is_enabled = True + platform_module.enabled_at = now + platform_module.enabled_by_user_id = user_id + elif not should_enable and platform_module.is_enabled: + platform_module.is_enabled = False + platform_module.disabled_at = now + platform_module.disabled_by_user_id = user_id + else: + # Create new record + platform_module = PlatformModule( + platform_id=platform_id, + module_code=code, + is_enabled=should_enable, + enabled_at=now if should_enable else None, + enabled_by_user_id=user_id if should_enable else None, + disabled_at=None if should_enable else now, + disabled_by_user_id=None if should_enable else user_id, + config={}, + ) + db.add(platform_module) logger.info( f"Updated enabled modules for platform {platform_id}: {sorted(enabled_set)}" @@ -353,16 +560,19 @@ class ModuleService: db: Session, platform_id: int, module_code: str, + user_id: int | None = None, ) -> bool: """ Enable a single module for a platform. Also enables required dependencies. + Uses junction table for auditability when available. Args: db: Database session platform_id: Platform ID module_code: Module code to enable + user_id: ID of user enabling the module (for audit) Returns: True if successful @@ -376,15 +586,45 @@ class ModuleService: logger.error(f"Platform {platform_id} not found") return False - settings = platform.settings or {} - enabled = set(settings.get("enabled_modules", list(MODULES.keys()))) - enabled.add(module_code) + # Migrate JSON settings to junction table if needed + self._migrate_json_to_junction_table(db, platform_id, user_id) - # Resolve dependencies - enabled = self._resolve_dependencies(enabled) + now = datetime.now(timezone.utc) - settings["enabled_modules"] = list(enabled) - platform.settings = settings + # Enable this module and its dependencies + modules_to_enable = {module_code} + module = get_module(module_code) + if module: + for required in module.requires: + modules_to_enable.add(required) + + for code in modules_to_enable: + # Check if junction table record exists + platform_module = ( + db.query(PlatformModule) + .filter( + PlatformModule.platform_id == platform_id, + PlatformModule.module_code == code, + ) + .first() + ) + + if platform_module: + # Update existing record + platform_module.is_enabled = True + platform_module.enabled_at = now + platform_module.enabled_by_user_id = user_id + else: + # Create new record + platform_module = PlatformModule( + platform_id=platform_id, + module_code=code, + is_enabled=True, + enabled_at=now, + enabled_by_user_id=user_id, + config={}, + ) + db.add(platform_module) logger.info(f"Enabled module '{module_code}' for platform {platform_id}") return True @@ -394,17 +634,20 @@ class ModuleService: db: Session, platform_id: int, module_code: str, + user_id: int | None = None, ) -> bool: """ Disable a single module for a platform. Core modules cannot be disabled. Also disables modules that depend on this one. + Uses junction table for auditability when available. Args: db: Database session platform_id: Platform ID module_code: Module code to disable + user_id: ID of user disabling the module (for audit) Returns: True if successful, False if core or not found @@ -423,23 +666,48 @@ class ModuleService: logger.error(f"Platform {platform_id} not found") return False - settings = platform.settings or {} - enabled = set(settings.get("enabled_modules", list(MODULES.keys()))) + # Migrate JSON settings to junction table if needed + self._migrate_json_to_junction_table(db, platform_id, user_id) - # Remove this module - enabled.discard(module_code) + now = datetime.now(timezone.utc) - # Remove modules that depend on this one + # Get modules to disable (this one + dependents) + modules_to_disable = {module_code} dependents = self._get_dependent_modules(module_code) - for dependent in dependents: - if dependent in enabled: - enabled.discard(dependent) - logger.info( - f"Also disabled '{dependent}' (depends on '{module_code}')" - ) + modules_to_disable.update(dependents) - settings["enabled_modules"] = list(enabled) - platform.settings = settings + for code in modules_to_disable: + # Check if junction table record exists + platform_module = ( + db.query(PlatformModule) + .filter( + PlatformModule.platform_id == platform_id, + PlatformModule.module_code == code, + ) + .first() + ) + + if platform_module: + # Update existing record + platform_module.is_enabled = False + platform_module.disabled_at = now + platform_module.disabled_by_user_id = user_id + else: + # Create disabled record for tracking + platform_module = PlatformModule( + platform_id=platform_id, + module_code=code, + is_enabled=False, + disabled_at=now, + disabled_by_user_id=user_id, + config={}, + ) + db.add(platform_module) + + if code != module_code: + logger.info( + f"Also disabled '{code}' (depends on '{module_code}')" + ) logger.info(f"Disabled module '{module_code}' for platform {platform_id}") return True diff --git a/app/templates/admin/module-config.html b/app/templates/admin/module-config.html new file mode 100644 index 00000000..75b3f205 --- /dev/null +++ b/app/templates/admin/module-config.html @@ -0,0 +1,148 @@ +{# app/templates/admin/module-config.html #} +{% extends "admin/base.html" %} +{% from 'shared/macros/alerts.html' import alert_dynamic, error_state %} +{% from 'shared/macros/headers.html' import page_header %} + +{% block title %}Module Configuration{% endblock %} + +{% block alpine_data %}adminModuleConfig('{{ platform_code }}', '{{ module_code }}'){% endblock %} + +{% block content %} +{{ page_header('Module Configuration', back_url='/admin/platforms/' + platform_code + '/modules') }} + +{{ alert_dynamic(type='success', title='Success', message_var='successMessage', show_condition='successMessage') }} +{{ error_state('Error', show_condition='error') }} + + +
+ Configure settings for this module on . +
+No configuration options available for this module.
+