From c419090531f82672191e6db94af649e37660d16e Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Mon, 26 Jan 2026 18:19:00 +0100 Subject: [PATCH] feat: complete modular platform architecture (Phases 1-5) Phase 1 - Vendor Router Integration: - Wire up vendor module routers in app/api/v1/vendor/__init__.py - Use lazy imports via __getattr__ to avoid circular dependencies Phase 2 - Extract Remaining Modules: - Create 6 new module directories: customers, cms, analytics, messaging, dev_tools, monitoring - Each module has definition.py and route wrappers - Update registry to import from extracted modules Phase 3 - Database Table Migration: - Add PlatformModule junction table for auditable module tracking - Add migration zc2m3n4o5p6q7_add_platform_modules_table.py - Add modules relationship to Platform model - Update ModuleService with JSON-to-junction-table migration Phase 4 - Module-Specific Configuration UI: - Add /api/v1/admin/module-config/* endpoints - Add module-config.html template and JS Phase 5 - Integration Tests: - Add tests/fixtures/module_fixtures.py - Add tests/integration/api/v1/admin/test_modules.py - Add tests/integration/api/v1/modules/test_module_access.py Architecture fixes: - Fix JS-003 errors: use ...data() directly in Alpine components - Fix JS-005 warnings: add init() guards to prevent duplicate init - Fix API-001 errors: add MenuActionResponse Pydantic model - Add FE-008 noqa for dynamic number input in template Co-Authored-By: Claude Opus 4.5 --- ...c2m3n4o5p6q7_add_platform_modules_table.py | 142 ++++++ app/api/v1/admin/__init__.py | 9 +- app/api/v1/admin/menu_config.py | 463 ++++++++++++++++++ app/api/v1/admin/module_config.py | 425 ++++++++++++++++ app/api/v1/vendor/__init__.py | 54 +- app/modules/analytics/__init__.py | 22 + app/modules/analytics/definition.py | 54 ++ app/modules/analytics/routes/__init__.py | 26 + app/modules/analytics/routes/vendor.py | 25 + app/modules/billing/routes/__init__.py | 20 +- app/modules/cms/__init__.py | 22 + app/modules/cms/definition.py | 66 +++ app/modules/cms/routes/__init__.py | 31 ++ app/modules/cms/routes/admin.py | 25 + app/modules/cms/routes/vendor.py | 39 ++ app/modules/customers/__init__.py | 22 + app/modules/customers/definition.py | 62 +++ app/modules/customers/routes/__init__.py | 28 ++ app/modules/customers/routes/admin.py | 25 + app/modules/customers/routes/vendor.py | 25 + app/modules/dev_tools/__init__.py | 21 + app/modules/dev_tools/definition.py | 35 ++ app/modules/dev_tools/routes/__init__.py | 15 + app/modules/inventory/routes/__init__.py | 20 +- app/modules/inventory/routes/admin.py | 6 +- app/modules/inventory/routes/vendor.py | 6 +- app/modules/marketplace/routes/__init__.py | 26 +- app/modules/marketplace/routes/admin.py | 10 +- app/modules/marketplace/routes/vendor.py | 10 +- app/modules/messaging/__init__.py | 22 + app/modules/messaging/definition.py | 77 +++ app/modules/messaging/routes/__init__.py | 34 ++ app/modules/messaging/routes/admin.py | 39 ++ app/modules/messaging/routes/vendor.py | 39 ++ app/modules/monitoring/__init__.py | 25 + app/modules/monitoring/definition.py | 59 +++ app/modules/monitoring/routes/__init__.py | 26 + app/modules/monitoring/routes/admin.py | 53 ++ app/modules/orders/routes/__init__.py | 20 +- app/modules/orders/routes/admin.py | 10 +- app/modules/orders/routes/vendor.py | 10 +- app/modules/registry.py | 139 +----- app/modules/service.py | 346 +++++++++++-- app/templates/admin/module-config.html | 148 ++++++ models/database/__init__.py | 6 + models/database/platform.py | 14 + models/database/platform_module.py | 162 ++++++ static/admin/js/module-config.js | 149 ++++++ static/admin/js/my-menu-config.js | 187 +++++++ static/admin/js/platform-menu-config.js | 212 ++++++++ static/admin/js/platform-modules.js | 12 +- tests/fixtures/module_fixtures.py | 159 ++++++ .../integration/api/v1/admin/test_modules.py | 328 +++++++++++++ tests/integration/api/v1/modules/__init__.py | 2 + .../api/v1/modules/test_module_access.py | 253 ++++++++++ 55 files changed, 4059 insertions(+), 206 deletions(-) create mode 100644 alembic/versions/zc2m3n4o5p6q7_add_platform_modules_table.py create mode 100644 app/api/v1/admin/menu_config.py create mode 100644 app/api/v1/admin/module_config.py create mode 100644 app/modules/analytics/__init__.py create mode 100644 app/modules/analytics/definition.py create mode 100644 app/modules/analytics/routes/__init__.py create mode 100644 app/modules/analytics/routes/vendor.py create mode 100644 app/modules/cms/__init__.py create mode 100644 app/modules/cms/definition.py create mode 100644 app/modules/cms/routes/__init__.py create mode 100644 app/modules/cms/routes/admin.py create mode 100644 app/modules/cms/routes/vendor.py create mode 100644 app/modules/customers/__init__.py create mode 100644 app/modules/customers/definition.py create mode 100644 app/modules/customers/routes/__init__.py create mode 100644 app/modules/customers/routes/admin.py create mode 100644 app/modules/customers/routes/vendor.py create mode 100644 app/modules/dev_tools/__init__.py create mode 100644 app/modules/dev_tools/definition.py create mode 100644 app/modules/dev_tools/routes/__init__.py create mode 100644 app/modules/messaging/__init__.py create mode 100644 app/modules/messaging/definition.py create mode 100644 app/modules/messaging/routes/__init__.py create mode 100644 app/modules/messaging/routes/admin.py create mode 100644 app/modules/messaging/routes/vendor.py create mode 100644 app/modules/monitoring/__init__.py create mode 100644 app/modules/monitoring/definition.py create mode 100644 app/modules/monitoring/routes/__init__.py create mode 100644 app/modules/monitoring/routes/admin.py create mode 100644 app/templates/admin/module-config.html create mode 100644 models/database/platform_module.py create mode 100644 static/admin/js/module-config.js create mode 100644 static/admin/js/my-menu-config.js create mode 100644 static/admin/js/platform-menu-config.js create mode 100644 tests/fixtures/module_fixtures.py create mode 100644 tests/integration/api/v1/admin/test_modules.py create mode 100644 tests/integration/api/v1/modules/__init__.py create mode 100644 tests/integration/api/v1/modules/test_module_access.py 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 . +

+
+ +
+
+ + +
+ + Loading configuration... +
+ + +
+
+

Configuration Options

+
+ + +
+ + + + +
+ + +
+ + + +
+
+ +{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/models/database/__init__.py b/models/database/__init__.py index fd2ba3ac..2e24b622 100644 --- a/models/database/__init__.py +++ b/models/database/__init__.py @@ -8,6 +8,7 @@ from .admin import ( AdminSetting, PlatformAlert, ) +from .admin_menu_config import AdminMenuConfig, FrontendType, MANDATORY_MENU_ITEMS from .admin_platform import AdminPlatform from .architecture_scan import ( ArchitectureScan, @@ -19,6 +20,7 @@ from .base import Base from .company import Company from .content_page import ContentPage from .platform import Platform +from .platform_module import PlatformModule from .vendor_platform import VendorPlatform from .customer import Customer, CustomerAddress from .password_reset_token import PasswordResetToken @@ -83,9 +85,12 @@ from .vendor_theme import VendorTheme __all__ = [ # Admin-specific models "AdminAuditLog", + "AdminMenuConfig", + "FrontendType", "AdminNotification", "AdminPlatform", "AdminSetting", + "MANDATORY_MENU_ITEMS", "PlatformAlert", "AdminSession", # Architecture/Code Quality @@ -112,6 +117,7 @@ __all__ = [ "ContentPage", # Platform "Platform", + "PlatformModule", "VendorPlatform", # Customer & Auth "Customer", diff --git a/models/database/platform.py b/models/database/platform.py index 301c9c46..f3d9abb5 100644 --- a/models/database/platform.py +++ b/models/database/platform.py @@ -199,6 +199,20 @@ class Platform(Base, TimestampMixin): cascade="all, delete-orphan", ) + # Menu visibility configuration for platform admins + menu_configs = relationship( + "AdminMenuConfig", + back_populates="platform", + cascade="all, delete-orphan", + ) + + # Module enablement configuration + modules = relationship( + "PlatformModule", + back_populates="platform", + cascade="all, delete-orphan", + ) + # ======================================================================== # Indexes # ======================================================================== diff --git a/models/database/platform_module.py b/models/database/platform_module.py new file mode 100644 index 00000000..57ca4232 --- /dev/null +++ b/models/database/platform_module.py @@ -0,0 +1,162 @@ +# models/database/platform_module.py +""" +PlatformModule model for tracking module enablement per platform. + +This junction table provides: +- 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 + +Replaces the simpler Platform.settings["enabled_modules"] JSON approach +for better auditability and query capabilities. +""" + +from sqlalchemy import ( + JSON, + Boolean, + Column, + DateTime, + ForeignKey, + Index, + Integer, + String, + UniqueConstraint, +) +from sqlalchemy.orm import relationship + +from app.core.database import Base +from models.database.base import TimestampMixin + + +class PlatformModule(Base, TimestampMixin): + """ + Junction table tracking module enablement per platform. + + This provides a normalized, auditable way to track which modules + are enabled for each platform, with configuration options. + + Example: + PlatformModule( + platform_id=1, + module_code="billing", + is_enabled=True, + enabled_at=datetime.now(), + enabled_by_user_id=42, + config={"stripe_mode": "live", "default_trial_days": 14} + ) + """ + + __tablename__ = "platform_modules" + + id = Column(Integer, primary_key=True, index=True) + + # ======================================================================== + # Identity + # ======================================================================== + + platform_id = Column( + Integer, + ForeignKey("platforms.id", ondelete="CASCADE"), + nullable=False, + comment="Platform this module configuration belongs to", + ) + + module_code = Column( + String(50), + nullable=False, + comment="Module code (e.g., 'billing', 'inventory', 'orders')", + ) + + # ======================================================================== + # State + # ======================================================================== + + is_enabled = Column( + Boolean, + nullable=False, + default=True, + comment="Whether this module is currently enabled for the platform", + ) + + # ======================================================================== + # Audit Trail - Enable + # ======================================================================== + + enabled_at = Column( + DateTime(timezone=True), + nullable=True, + comment="When the module was last enabled", + ) + + enabled_by_user_id = Column( + Integer, + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + comment="User who enabled the module", + ) + + # ======================================================================== + # Audit Trail - Disable + # ======================================================================== + + disabled_at = Column( + DateTime(timezone=True), + nullable=True, + comment="When the module was last disabled", + ) + + disabled_by_user_id = Column( + Integer, + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + comment="User who disabled the module", + ) + + # ======================================================================== + # Configuration + # ======================================================================== + + config = Column( + JSON, + nullable=False, + default=dict, + comment="Module-specific configuration for this platform", + ) + + # ======================================================================== + # Relationships + # ======================================================================== + + platform = relationship( + "Platform", + back_populates="modules", + ) + + enabled_by = relationship( + "User", + foreign_keys=[enabled_by_user_id], + ) + + disabled_by = relationship( + "User", + foreign_keys=[disabled_by_user_id], + ) + + # ======================================================================== + # Constraints & Indexes + # ======================================================================== + + __table_args__ = ( + # Each platform can only have one configuration per module + UniqueConstraint("platform_id", "module_code", name="uq_platform_module"), + # Index for querying by platform + Index("idx_platform_module_platform_id", "platform_id"), + # Index for querying by module code + Index("idx_platform_module_code", "module_code"), + # Index for querying enabled modules + Index("idx_platform_module_enabled", "platform_id", "is_enabled"), + ) + + def __repr__(self) -> str: + status = "enabled" if self.is_enabled else "disabled" + return f"" diff --git a/static/admin/js/module-config.js b/static/admin/js/module-config.js new file mode 100644 index 00000000..5983882d --- /dev/null +++ b/static/admin/js/module-config.js @@ -0,0 +1,149 @@ +// static/admin/js/module-config.js +// Module configuration management for platform modules + +const moduleConfigLog = window.LogConfig?.loggers?.moduleConfig || window.LogConfig?.createLogger?.('moduleConfig') || console; + +function adminModuleConfig(platformCode, moduleCode) { + return { + // Inherit base layout functionality from init-alpine.js + ...data(), + + // Page-specific state + currentPage: 'platforms', + platformCode: platformCode, + moduleCode: moduleCode, + loading: true, + error: null, + successMessage: null, + saving: false, + + // Data + platformId: null, + platformName: '', + moduleInfo: null, + config: {}, + + async init() { + // Guard against duplicate initialization + if (window._moduleConfigInitialized) { + moduleConfigLog.warn('Already initialized, skipping'); + return; + } + window._moduleConfigInitialized = true; + + moduleConfigLog.info('=== MODULE CONFIG PAGE INITIALIZING ==='); + moduleConfigLog.info('Platform code:', this.platformCode); + moduleConfigLog.info('Module code:', this.moduleCode); + + try { + await this.loadPlatform(); + await this.loadModuleConfig(); + moduleConfigLog.info('=== MODULE CONFIG PAGE INITIALIZED ==='); + } catch (error) { + moduleConfigLog.error('Failed to initialize module config page:', error); + this.error = 'Failed to load page data. Please refresh.'; + } + }, + + async refresh() { + this.error = null; + this.successMessage = null; + await this.loadModuleConfig(); + }, + + async loadPlatform() { + try { + const platform = await apiClient.get(`/admin/platforms/${this.platformCode}`); + this.platformId = platform.id; + this.platformName = platform.name; + moduleConfigLog.info('Loaded platform:', platform.name); + } catch (error) { + moduleConfigLog.error('Failed to load platform:', error); + throw error; + } + }, + + async loadModuleConfig() { + this.loading = true; + this.error = null; + + try { + if (!this.platformId) { + throw new Error('Platform not loaded'); + } + + this.moduleInfo = await apiClient.get( + `/admin/module-config/platforms/${this.platformId}/modules/${this.moduleCode}/config` + ); + + // Initialize config with current values + this.config = { ...this.moduleInfo.config }; + + moduleConfigLog.info('Loaded module config:', { + moduleCode: this.moduleCode, + config: this.config + }); + } catch (error) { + moduleConfigLog.error('Failed to load module config:', error); + this.error = error.message || 'Failed to load module configuration'; + } finally { + this.loading = false; + } + }, + + async saveConfig() { + this.saving = true; + this.error = null; + this.successMessage = null; + + try { + const response = await apiClient.put( + `/admin/module-config/platforms/${this.platformId}/modules/${this.moduleCode}/config`, + { config: this.config } + ); + + // Update local state with response + this.moduleInfo = response; + this.config = { ...response.config }; + + this.successMessage = 'Configuration saved successfully'; + moduleConfigLog.info('Saved module config:', this.config); + } catch (error) { + moduleConfigLog.error('Failed to save module config:', error); + this.error = error.message || 'Failed to save configuration'; + } finally { + this.saving = false; + } + }, + + async resetToDefaults() { + if (!confirm('This will reset all configuration options to their default values. Continue?')) { + return; + } + + this.saving = true; + this.error = null; + this.successMessage = null; + + try { + const response = await apiClient.post( + `/admin/module-config/platforms/${this.platformId}/modules/${this.moduleCode}/reset` + ); + + // Update local state with defaults + this.config = { ...response.config }; + + // Reload full config to get schema_info + await this.loadModuleConfig(); + + this.successMessage = 'Configuration reset to defaults'; + moduleConfigLog.info('Reset module config to defaults'); + } catch (error) { + moduleConfigLog.error('Failed to reset module config:', error); + this.error = error.message || 'Failed to reset configuration'; + } finally { + this.saving = false; + } + } + }; +} diff --git a/static/admin/js/my-menu-config.js b/static/admin/js/my-menu-config.js new file mode 100644 index 00000000..973ebc4a --- /dev/null +++ b/static/admin/js/my-menu-config.js @@ -0,0 +1,187 @@ +// static/admin/js/my-menu-config.js +// Personal menu configuration for super admins +// +// TODO: BUG - Sidebar menu doesn't update immediately after changes. +// User must navigate to another page to see the updated menu. +// The issue is that Alpine.js doesn't properly track reactivity for the +// visibleMenuItems Set in init-alpine.js. Attempted fixes with reloadSidebarMenu() +// and window.location.reload() didn't work reliably. +// Possible solutions: +// 1. Convert visibleMenuItems from Set to plain object for better Alpine reactivity +// 2. Use Alpine.store() for shared state between components +// 3. Dispatch a custom event that the sidebar listens for +// 4. Force re-render of sidebar component after changes + +const myMenuConfigLog = window.LogConfig?.loggers?.myMenuConfig || window.LogConfig?.createLogger?.('myMenuConfig') || console; + +function adminMyMenuConfig() { + return { + // Inherit base layout functionality from init-alpine.js + ...data(), + + // Page-specific state + currentPage: 'my-menu', + loading: true, + error: null, + successMessage: null, + saving: false, + + // Data + menuConfig: null, + + // Computed grouped items + get groupedItems() { + if (!this.menuConfig?.items) return []; + + // Group items by section + const sections = {}; + for (const item of this.menuConfig.items) { + const sectionId = item.section_id; + if (!sections[sectionId]) { + sections[sectionId] = { + id: sectionId, + label: item.section_label, + isSuperAdminOnly: item.is_super_admin_only, + items: [], + visibleCount: 0 + }; + } + sections[sectionId].items.push(item); + if (item.is_visible) { + sections[sectionId].visibleCount++; + } + } + + // Convert to array and maintain order + return Object.values(sections); + }, + + async init() { + // Guard against multiple initialization + if (window._adminMyMenuConfigInitialized) { + myMenuConfigLog.warn('Already initialized, skipping'); + return; + } + window._adminMyMenuConfigInitialized = true; + + myMenuConfigLog.info('=== MY MENU CONFIG PAGE INITIALIZING ==='); + + try { + await this.loadMenuConfig(); + myMenuConfigLog.info('=== MY MENU CONFIG PAGE INITIALIZED ==='); + } catch (error) { + myMenuConfigLog.error('Failed to initialize my menu config page:', error); + this.error = 'Failed to load page data. Please refresh.'; + } + }, + + async refresh() { + this.error = null; + this.successMessage = null; + await this.loadMenuConfig(); + }, + + async loadMenuConfig() { + this.loading = true; + this.error = null; + + try { + this.menuConfig = await apiClient.get('/admin/menu-config/user'); + myMenuConfigLog.info('Loaded menu config:', { + totalItems: this.menuConfig?.total_items, + visibleItems: this.menuConfig?.visible_items + }); + } catch (error) { + myMenuConfigLog.error('Failed to load menu config:', error); + this.error = error.message || 'Failed to load menu configuration'; + } finally { + this.loading = false; + } + }, + + async toggleVisibility(item) { + if (item.is_mandatory) { + myMenuConfigLog.warn('Cannot toggle mandatory item:', item.id); + return; + } + + this.saving = true; + this.error = null; + this.successMessage = null; + + const newVisibility = !item.is_visible; + + try { + await apiClient.put('/admin/menu-config/user', { + menu_item_id: item.id, + is_visible: newVisibility + }); + + // Update local state + item.is_visible = newVisibility; + + // Update counts + if (newVisibility) { + this.menuConfig.visible_items++; + this.menuConfig.hidden_items--; + } else { + this.menuConfig.visible_items--; + this.menuConfig.hidden_items++; + } + + myMenuConfigLog.info('Toggled visibility:', item.id, newVisibility); + + // Reload the page to refresh sidebar + window.location.reload(); + } catch (error) { + myMenuConfigLog.error('Failed to toggle visibility:', error); + this.error = error.message || 'Failed to update menu visibility'; + this.saving = false; + } + }, + + async showAll() { + if (!confirm('This will show all menu items. Continue?')) { + return; + } + + this.saving = true; + this.error = null; + this.successMessage = null; + + try { + await apiClient.post('/admin/menu-config/user/show-all'); + myMenuConfigLog.info('Showed all menu items'); + + // Reload the page to refresh sidebar + window.location.reload(); + } catch (error) { + myMenuConfigLog.error('Failed to show all menu items:', error); + this.error = error.message || 'Failed to show all menu items'; + this.saving = false; + } + }, + + async resetToDefaults() { + if (!confirm('This will hide all menu items (except mandatory ones). You can then enable the ones you want. Continue?')) { + return; + } + + this.saving = true; + this.error = null; + this.successMessage = null; + + try { + await apiClient.post('/admin/menu-config/user/reset'); + myMenuConfigLog.info('Reset menu config to defaults'); + + // Reload the page to refresh sidebar + window.location.reload(); + } catch (error) { + myMenuConfigLog.error('Failed to reset menu config:', error); + this.error = error.message || 'Failed to reset menu configuration'; + this.saving = false; + } + } + }; +} diff --git a/static/admin/js/platform-menu-config.js b/static/admin/js/platform-menu-config.js new file mode 100644 index 00000000..7a59a938 --- /dev/null +++ b/static/admin/js/platform-menu-config.js @@ -0,0 +1,212 @@ +// static/admin/js/platform-menu-config.js +// Platform menu configuration management +// +// TODO: BUG - Sidebar menu doesn't update immediately after changes. +// See my-menu-config.js for details and possible solutions. + +const menuConfigLog = window.LogConfig?.loggers?.menuConfig || window.LogConfig?.createLogger?.('menuConfig') || console; + +function adminPlatformMenuConfig(platformCode) { + return { + // Inherit base layout functionality from init-alpine.js + ...data(), + + // Page-specific state + currentPage: 'platforms', + platformCode: platformCode, + loading: true, + error: null, + successMessage: null, + saving: false, + + // Data + platform: null, + menuConfig: null, + frontendType: 'admin', + + // Computed grouped items + get groupedItems() { + if (!this.menuConfig?.items) return []; + + // Group items by section + const sections = {}; + for (const item of this.menuConfig.items) { + const sectionId = item.section_id; + if (!sections[sectionId]) { + sections[sectionId] = { + id: sectionId, + label: item.section_label, + isSuperAdminOnly: item.is_super_admin_only, + items: [], + visibleCount: 0 + }; + } + sections[sectionId].items.push(item); + if (item.is_visible) { + sections[sectionId].visibleCount++; + } + } + + // Convert to array and maintain order + return Object.values(sections); + }, + + async init() { + // Guard against duplicate initialization + if (window._platformMenuConfigInitialized) { + menuConfigLog.warn('Already initialized, skipping'); + return; + } + window._platformMenuConfigInitialized = true; + + menuConfigLog.info('=== PLATFORM MENU CONFIG PAGE INITIALIZING ==='); + menuConfigLog.info('Platform code:', this.platformCode); + + try { + await this.loadPlatform(); + await this.loadPlatformMenuConfig(); + menuConfigLog.info('=== PLATFORM MENU CONFIG PAGE INITIALIZED ==='); + } catch (error) { + menuConfigLog.error('Failed to initialize menu config page:', error); + this.error = 'Failed to load page data. Please refresh.'; + } + }, + + async refresh() { + this.error = null; + this.successMessage = null; + await this.loadPlatformMenuConfig(); + }, + + async loadPlatform() { + try { + this.platform = await apiClient.get(`/admin/platforms/${this.platformCode}`); + menuConfigLog.info('Loaded platform:', this.platform?.name); + } catch (error) { + menuConfigLog.error('Failed to load platform:', error); + throw error; + } + }, + + async loadPlatformMenuConfig() { + this.loading = true; + this.error = null; + + try { + const platformId = this.platform?.id; + if (!platformId) { + throw new Error('Platform not loaded'); + } + + const params = new URLSearchParams({ frontend_type: this.frontendType }); + this.menuConfig = await apiClient.get(`/admin/menu-config/platforms/${platformId}?${params}`); + menuConfigLog.info('Loaded menu config:', { + frontendType: this.frontendType, + totalItems: this.menuConfig?.total_items, + visibleItems: this.menuConfig?.visible_items + }); + } catch (error) { + menuConfigLog.error('Failed to load menu config:', error); + this.error = error.message || 'Failed to load menu configuration'; + } finally { + this.loading = false; + } + }, + + async toggleVisibility(item) { + if (item.is_mandatory) { + menuConfigLog.warn('Cannot toggle mandatory item:', item.id); + return; + } + + this.saving = true; + this.error = null; + this.successMessage = null; + + const newVisibility = !item.is_visible; + + try { + const platformId = this.platform?.id; + const params = new URLSearchParams({ frontend_type: this.frontendType }); + + await apiClient.put(`/admin/menu-config/platforms/${platformId}?${params}`, { + menu_item_id: item.id, + is_visible: newVisibility + }); + + // Update local state + item.is_visible = newVisibility; + + // Update counts + if (newVisibility) { + this.menuConfig.visible_items++; + this.menuConfig.hidden_items--; + } else { + this.menuConfig.visible_items--; + this.menuConfig.hidden_items++; + } + + menuConfigLog.info('Toggled visibility:', item.id, newVisibility); + + // Reload the page to refresh sidebar + window.location.reload(); + } catch (error) { + menuConfigLog.error('Failed to toggle visibility:', error); + this.error = error.message || 'Failed to update menu visibility'; + this.saving = false; + } + }, + + async showAll() { + if (!confirm('This will show all menu items. Continue?')) { + return; + } + + this.saving = true; + this.error = null; + this.successMessage = null; + + try { + const platformId = this.platform?.id; + const params = new URLSearchParams({ frontend_type: this.frontendType }); + + await apiClient.post(`/admin/menu-config/platforms/${platformId}/show-all?${params}`); + + menuConfigLog.info('Showed all menu items'); + + // Reload the page to refresh sidebar + window.location.reload(); + } catch (error) { + menuConfigLog.error('Failed to show all menu items:', error); + this.error = error.message || 'Failed to show all menu items'; + this.saving = false; + } + }, + + async resetToDefaults() { + if (!confirm('This will hide all menu items (except mandatory ones). You can then enable the ones you want. Continue?')) { + return; + } + + this.saving = true; + this.error = null; + this.successMessage = null; + + try { + const platformId = this.platform?.id; + const params = new URLSearchParams({ frontend_type: this.frontendType }); + + await apiClient.post(`/admin/menu-config/platforms/${platformId}/reset?${params}`); + + menuConfigLog.info('Reset menu config to defaults'); + + // Reload the page to refresh sidebar + window.location.reload(); + } catch (error) { + menuConfigLog.error('Failed to reset menu config:', error); + this.error = error.message || 'Failed to reset menu configuration'; + this.saving = false; + } + } + }; +} diff --git a/static/admin/js/platform-modules.js b/static/admin/js/platform-modules.js index e12dbbcf..78cc7880 100644 --- a/static/admin/js/platform-modules.js +++ b/static/admin/js/platform-modules.js @@ -4,12 +4,9 @@ const moduleConfigLog = window.LogConfig?.loggers?.moduleConfig || window.LogConfig?.createLogger?.('moduleConfig') || console; function adminPlatformModules(platformCode) { - // Get base data with safety check for standalone usage - const baseData = typeof data === 'function' ? data() : {}; - return { // Inherit base layout functionality from init-alpine.js - ...baseData, + ...data(), // Page-specific state currentPage: 'platforms', @@ -62,6 +59,13 @@ function adminPlatformModules(platformCode) { }, async init() { + // Guard against duplicate initialization + if (window._platformModulesInitialized) { + moduleConfigLog.warn('Already initialized, skipping'); + return; + } + window._platformModulesInitialized = true; + moduleConfigLog.info('=== PLATFORM MODULES PAGE INITIALIZING ==='); moduleConfigLog.info('Platform code:', this.platformCode); diff --git a/tests/fixtures/module_fixtures.py b/tests/fixtures/module_fixtures.py new file mode 100644 index 00000000..e467e6bb --- /dev/null +++ b/tests/fixtures/module_fixtures.py @@ -0,0 +1,159 @@ +# tests/fixtures/module_fixtures.py +""" +Module system test fixtures. + +Provides fixtures for testing platform module enablement, configuration, +and access control. +""" + +import uuid +from datetime import datetime, timezone + +import pytest + +from models.database.platform import Platform +from models.database.platform_module import PlatformModule + + +@pytest.fixture +def platform_with_modules(db, test_super_admin): + """Create a test platform with specific modules enabled.""" + unique_id = str(uuid.uuid4())[:8] + platform = Platform( + code=f"modtest_{unique_id}", + name=f"Module Test Platform {unique_id}", + description="A test platform with module configuration", + path_prefix=f"modtest{unique_id}", + is_active=True, + is_public=True, + default_language="en", + supported_languages=["en", "fr"], + ) + db.add(platform) + db.flush() + + # Enable specific modules via junction table + enabled_modules = ["billing", "inventory", "orders"] + for module_code in enabled_modules: + pm = PlatformModule( + platform_id=platform.id, + module_code=module_code, + is_enabled=True, + enabled_at=datetime.now(timezone.utc), + enabled_by_user_id=test_super_admin.id, + config={}, + ) + db.add(pm) + + # Add a disabled module + pm_disabled = PlatformModule( + platform_id=platform.id, + module_code="marketplace", + is_enabled=False, + disabled_at=datetime.now(timezone.utc), + disabled_by_user_id=test_super_admin.id, + config={}, + ) + db.add(pm_disabled) + + db.commit() + db.refresh(platform) + return platform + + +@pytest.fixture +def platform_with_config(db, test_super_admin): + """Create a test platform with module configuration.""" + unique_id = str(uuid.uuid4())[:8] + platform = Platform( + code=f"cfgtest_{unique_id}", + name=f"Config Test Platform {unique_id}", + description="A test platform with module config", + path_prefix=f"cfgtest{unique_id}", + is_active=True, + is_public=True, + default_language="en", + supported_languages=["en"], + ) + db.add(platform) + db.flush() + + # Add module with configuration + pm = PlatformModule( + platform_id=platform.id, + module_code="billing", + is_enabled=True, + enabled_at=datetime.now(timezone.utc), + enabled_by_user_id=test_super_admin.id, + config={ + "stripe_mode": "test", + "default_trial_days": 30, + "allow_free_tier": True, + }, + ) + db.add(pm) + + # Add inventory module with config + pm_inv = PlatformModule( + platform_id=platform.id, + module_code="inventory", + is_enabled=True, + enabled_at=datetime.now(timezone.utc), + enabled_by_user_id=test_super_admin.id, + config={ + "low_stock_threshold": 5, + "enable_locations": True, + }, + ) + db.add(pm_inv) + + db.commit() + db.refresh(platform) + return platform + + +@pytest.fixture +def platform_all_modules_disabled(db, test_super_admin): + """Create a test platform with all optional modules disabled.""" + unique_id = str(uuid.uuid4())[:8] + platform = Platform( + code=f"nomod_{unique_id}", + name=f"No Modules Platform {unique_id}", + description="A test platform with minimal modules", + path_prefix=f"nomod{unique_id}", + is_active=True, + is_public=True, + default_language="en", + supported_languages=["en"], + settings={"enabled_modules": []}, # Legacy format for testing + ) + db.add(platform) + db.commit() + db.refresh(platform) + return platform + + +@pytest.fixture +def module_factory(db, test_super_admin): + """Factory for creating PlatformModule records.""" + def _create_module( + platform_id: int, + module_code: str, + is_enabled: bool = True, + config: dict | None = None, + ): + pm = PlatformModule( + platform_id=platform_id, + module_code=module_code, + is_enabled=is_enabled, + enabled_at=datetime.now(timezone.utc) if is_enabled else None, + enabled_by_user_id=test_super_admin.id if is_enabled else None, + disabled_at=None if is_enabled else datetime.now(timezone.utc), + disabled_by_user_id=None if is_enabled else test_super_admin.id, + config=config or {}, + ) + db.add(pm) + db.commit() + db.refresh(pm) + return pm + return _create_module diff --git a/tests/integration/api/v1/admin/test_modules.py b/tests/integration/api/v1/admin/test_modules.py new file mode 100644 index 00000000..7d223e6d --- /dev/null +++ b/tests/integration/api/v1/admin/test_modules.py @@ -0,0 +1,328 @@ +# tests/integration/api/v1/admin/test_modules.py +""" +Integration tests for admin module management endpoints. + +Tests the /api/v1/admin/modules/* and /api/v1/admin/module-config/* endpoints. +All endpoints require super admin access. +""" + +import pytest + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.admin +@pytest.mark.modules +class TestAdminModulesAPI: + """Tests for admin module management endpoints.""" + + # ======================================================================== + # List Modules Tests + # ======================================================================== + + def test_list_all_modules(self, client, super_admin_headers): + """Test super admin listing all modules.""" + response = client.get("/api/v1/admin/modules", headers=super_admin_headers) + + assert response.status_code == 200 + data = response.json() + assert "modules" in data + assert "total" in data + assert data["total"] >= 10 # At least 10 modules defined + + # Check expected modules exist + module_codes = [m["code"] for m in data["modules"]] + assert "core" in module_codes + assert "billing" in module_codes + assert "inventory" in module_codes + + def test_list_modules_requires_super_admin(self, client, admin_headers): + """Test that listing modules requires super admin.""" + response = client.get("/api/v1/admin/modules", headers=admin_headers) + + # Should require super admin + assert response.status_code == 403 + + def test_list_modules_unauthenticated(self, client): + """Test that listing modules requires authentication.""" + response = client.get("/api/v1/admin/modules") + + assert response.status_code == 401 + + # ======================================================================== + # Get Platform Modules Tests + # ======================================================================== + + def test_get_platform_modules(self, client, super_admin_headers, test_platform): + """Test getting modules for a specific platform.""" + response = client.get( + f"/api/v1/admin/modules/platforms/{test_platform.id}", + headers=super_admin_headers, + ) + + assert response.status_code == 200 + data = response.json() + assert data["platform_id"] == test_platform.id + assert data["platform_code"] == test_platform.code + assert "modules" in data + assert "enabled" in data + assert "disabled" in data + + def test_get_platform_modules_not_found(self, client, super_admin_headers): + """Test getting modules for non-existent platform.""" + response = client.get( + "/api/v1/admin/modules/platforms/99999", + headers=super_admin_headers, + ) + + assert response.status_code == 404 + + # ======================================================================== + # Enable/Disable Module Tests + # ======================================================================== + + def test_enable_module(self, client, super_admin_headers, test_platform, db): + """Test enabling a module for a platform.""" + # First disable the module via settings + test_platform.settings = {"enabled_modules": ["core", "platform-admin"]} + db.commit() + + response = client.post( + f"/api/v1/admin/modules/platforms/{test_platform.id}/enable", + headers=super_admin_headers, + json={"module_code": "billing"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert "billing" in data["message"].lower() or "enabled" in data["message"].lower() + + def test_disable_module(self, client, super_admin_headers, test_platform, db): + """Test disabling a module for a platform.""" + # Ensure module is enabled + test_platform.settings = {"enabled_modules": ["billing", "inventory"]} + db.commit() + + response = client.post( + f"/api/v1/admin/modules/platforms/{test_platform.id}/disable", + headers=super_admin_headers, + json={"module_code": "billing"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + + def test_cannot_disable_core_module(self, client, super_admin_headers, test_platform): + """Test that core modules cannot be disabled.""" + response = client.post( + f"/api/v1/admin/modules/platforms/{test_platform.id}/disable", + headers=super_admin_headers, + json={"module_code": "core"}, + ) + + assert response.status_code == 400 + data = response.json() + assert "core" in data.get("message", "").lower() or "cannot" in data.get("message", "").lower() + + def test_enable_invalid_module(self, client, super_admin_headers, test_platform): + """Test enabling a non-existent module.""" + response = client.post( + f"/api/v1/admin/modules/platforms/{test_platform.id}/enable", + headers=super_admin_headers, + json={"module_code": "invalid_module"}, + ) + + assert response.status_code == 400 + + # ======================================================================== + # Bulk Operations Tests + # ======================================================================== + + def test_update_platform_modules(self, client, super_admin_headers, test_platform): + """Test updating all enabled modules at once.""" + response = client.put( + f"/api/v1/admin/modules/platforms/{test_platform.id}", + headers=super_admin_headers, + json={"module_codes": ["billing", "inventory", "orders"]}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["platform_id"] == test_platform.id + + # Check that specified modules are enabled + enabled_codes = [m["code"] for m in data["modules"] if m["is_enabled"]] + assert "billing" in enabled_codes + assert "inventory" in enabled_codes + assert "orders" in enabled_codes + # Core modules should always be enabled + assert "core" in enabled_codes + + def test_enable_all_modules(self, client, super_admin_headers, test_platform): + """Test enabling all modules for a platform.""" + response = client.post( + f"/api/v1/admin/modules/platforms/{test_platform.id}/enable-all", + headers=super_admin_headers, + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["enabled_count"] >= 10 + + def test_disable_optional_modules(self, client, super_admin_headers, test_platform): + """Test disabling all optional modules.""" + response = client.post( + f"/api/v1/admin/modules/platforms/{test_platform.id}/disable-optional", + headers=super_admin_headers, + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert "core" in data["core_modules"] + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.admin +@pytest.mark.modules +class TestAdminModuleConfigAPI: + """Tests for admin module configuration endpoints.""" + + # ======================================================================== + # Get Module Config Tests + # ======================================================================== + + def test_get_module_config(self, client, super_admin_headers, test_platform): + """Test getting module configuration.""" + response = client.get( + f"/api/v1/admin/module-config/platforms/{test_platform.id}/modules/billing/config", + headers=super_admin_headers, + ) + + assert response.status_code == 200 + data = response.json() + assert data["module_code"] == "billing" + assert "config" in data + assert "schema_info" in data + assert "defaults" in data + + def test_get_module_config_has_defaults(self, client, super_admin_headers, test_platform): + """Test that module config includes default values.""" + response = client.get( + f"/api/v1/admin/module-config/platforms/{test_platform.id}/modules/billing/config", + headers=super_admin_headers, + ) + + assert response.status_code == 200 + data = response.json() + + # Should have default billing config + assert "stripe_mode" in data["config"] + assert "default_trial_days" in data["config"] + + def test_get_module_config_invalid_module(self, client, super_admin_headers, test_platform): + """Test getting config for invalid module.""" + response = client.get( + f"/api/v1/admin/module-config/platforms/{test_platform.id}/modules/invalid_module/config", + headers=super_admin_headers, + ) + + assert response.status_code == 400 + + # ======================================================================== + # Update Module Config Tests + # ======================================================================== + + def test_update_module_config(self, client, super_admin_headers, test_platform): + """Test updating module configuration.""" + response = client.put( + f"/api/v1/admin/module-config/platforms/{test_platform.id}/modules/billing/config", + headers=super_admin_headers, + json={ + "config": { + "stripe_mode": "live", + "default_trial_days": 7, + } + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["config"]["stripe_mode"] == "live" + assert data["config"]["default_trial_days"] == 7 + + def test_update_module_config_persists(self, client, super_admin_headers, test_platform): + """Test that config updates persist across requests.""" + # Update config + client.put( + f"/api/v1/admin/module-config/platforms/{test_platform.id}/modules/inventory/config", + headers=super_admin_headers, + json={ + "config": { + "low_stock_threshold": 25, + } + }, + ) + + # Fetch again + response = client.get( + f"/api/v1/admin/module-config/platforms/{test_platform.id}/modules/inventory/config", + headers=super_admin_headers, + ) + + assert response.status_code == 200 + data = response.json() + assert data["config"]["low_stock_threshold"] == 25 + + # ======================================================================== + # Reset Config Tests + # ======================================================================== + + def test_reset_module_config(self, client, super_admin_headers, test_platform): + """Test resetting module config to defaults.""" + # First set custom config + client.put( + f"/api/v1/admin/module-config/platforms/{test_platform.id}/modules/billing/config", + headers=super_admin_headers, + json={ + "config": { + "stripe_mode": "live", + "default_trial_days": 1, + } + }, + ) + + # Reset to defaults + response = client.post( + f"/api/v1/admin/module-config/platforms/{test_platform.id}/modules/billing/reset", + headers=super_admin_headers, + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + # Config should be reset to defaults + assert data["config"]["stripe_mode"] == "test" + assert data["config"]["default_trial_days"] == 14 + + # ======================================================================== + # Get Defaults Tests + # ======================================================================== + + def test_get_config_defaults(self, client, super_admin_headers): + """Test getting default config for a module.""" + response = client.get( + "/api/v1/admin/module-config/defaults/billing", + headers=super_admin_headers, + ) + + assert response.status_code == 200 + data = response.json() + assert data["module_code"] == "billing" + assert "defaults" in data + assert "schema_info" in data + assert data["defaults"]["stripe_mode"] == "test" diff --git a/tests/integration/api/v1/modules/__init__.py b/tests/integration/api/v1/modules/__init__.py new file mode 100644 index 00000000..8eca53db --- /dev/null +++ b/tests/integration/api/v1/modules/__init__.py @@ -0,0 +1,2 @@ +# tests/integration/api/v1/modules/__init__.py +"""Integration tests for module access control.""" diff --git a/tests/integration/api/v1/modules/test_module_access.py b/tests/integration/api/v1/modules/test_module_access.py new file mode 100644 index 00000000..10b04a05 --- /dev/null +++ b/tests/integration/api/v1/modules/test_module_access.py @@ -0,0 +1,253 @@ +# tests/integration/api/v1/modules/test_module_access.py +""" +Integration tests for module-based access control. + +Tests verify that: +- Disabled modules return 403 Forbidden +- Enabled modules allow access +- Core modules are always accessible +- Module dependencies are enforced +""" + +import pytest + +from models.database.platform import Platform + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.modules +class TestModuleAccessControl: + """Tests for module-based access control on API endpoints.""" + + # ======================================================================== + # Billing Module Access Tests + # ======================================================================== + + def test_billing_accessible_when_enabled( + self, client, auth_headers, test_vendor, db + ): + """Test billing endpoints accessible when module enabled.""" + # Ensure billing module is enabled (default - no config means all enabled) + response = client.get( + "/api/v1/vendor/billing/subscription", + headers=auth_headers, + ) + + # Should succeed (200) or have other error, but NOT 403 for module + assert response.status_code != 403 or "module" not in response.json().get("message", "").lower() + + def test_billing_forbidden_when_disabled( + self, client, auth_headers, test_vendor, db, test_platform + ): + """Test billing endpoints return 403 when module disabled.""" + # Disable billing module + test_platform.settings = {"enabled_modules": ["core", "platform-admin", "inventory"]} + db.commit() + + response = client.get( + "/api/v1/vendor/billing/subscription", + headers=auth_headers, + ) + + # Should return 403 with module disabled message + assert response.status_code == 403 + data = response.json() + assert "module" in data.get("message", "").lower() or data.get("error_code") == "MODULE_DISABLED" + + # ======================================================================== + # Inventory Module Access Tests + # ======================================================================== + + def test_inventory_accessible_when_enabled( + self, client, auth_headers, test_inventory + ): + """Test inventory endpoints accessible when module enabled.""" + response = client.get( + "/api/v1/vendor/inventory", + headers=auth_headers, + ) + + # Should succeed + assert response.status_code == 200 + + def test_inventory_forbidden_when_disabled( + self, client, auth_headers, db, test_platform + ): + """Test inventory endpoints return 403 when module disabled.""" + # Disable inventory module + test_platform.settings = {"enabled_modules": ["core", "platform-admin", "billing"]} + db.commit() + + response = client.get( + "/api/v1/vendor/inventory", + headers=auth_headers, + ) + + # Should return 403 + assert response.status_code == 403 + data = response.json() + assert "module" in data.get("message", "").lower() or data.get("error_code") == "MODULE_DISABLED" + + # ======================================================================== + # Orders Module Access Tests + # ======================================================================== + + def test_orders_accessible_when_enabled( + self, client, auth_headers, test_order + ): + """Test orders endpoints accessible when module enabled.""" + response = client.get( + "/api/v1/vendor/orders", + headers=auth_headers, + ) + + # Should succeed + assert response.status_code == 200 + + def test_orders_forbidden_when_disabled( + self, client, auth_headers, db, test_platform + ): + """Test orders endpoints return 403 when module disabled.""" + # Disable orders module + test_platform.settings = {"enabled_modules": ["core", "platform-admin"]} + db.commit() + + response = client.get( + "/api/v1/vendor/orders", + headers=auth_headers, + ) + + # Should return 403 + assert response.status_code == 403 + + # ======================================================================== + # Marketplace Module Access Tests + # ======================================================================== + + def test_marketplace_accessible_when_enabled( + self, client, auth_headers + ): + """Test marketplace endpoints accessible when module enabled.""" + response = client.get( + "/api/v1/vendor/marketplace/settings", + headers=auth_headers, + ) + + # Should not return 403 for module disabled + # (might be 404 if no settings exist, or 200) + assert response.status_code != 403 or "module" not in response.json().get("message", "").lower() + + def test_marketplace_forbidden_when_disabled( + self, client, auth_headers, db, test_platform + ): + """Test marketplace endpoints return 403 when module disabled.""" + # Disable marketplace module but keep inventory (its dependency) + test_platform.settings = {"enabled_modules": ["core", "platform-admin", "inventory"]} + db.commit() + + response = client.get( + "/api/v1/vendor/marketplace/settings", + headers=auth_headers, + ) + + # Should return 403 + assert response.status_code == 403 + + # ======================================================================== + # Core Module Tests + # ======================================================================== + + def test_core_always_accessible( + self, client, auth_headers, db, test_platform + ): + """Test core endpoints always accessible even with empty modules.""" + # Set empty module list (but core is always added) + test_platform.settings = {"enabled_modules": []} + db.commit() + + # Dashboard is a core endpoint + response = client.get( + "/api/v1/vendor/dashboard", + headers=auth_headers, + ) + + # Should NOT return 403 for module disabled + assert response.status_code != 403 or "module" not in response.json().get("message", "").lower() + + # ======================================================================== + # Admin Module Access Tests + # ======================================================================== + + def test_admin_inventory_accessible_when_enabled( + self, client, admin_headers, test_inventory + ): + """Test admin inventory endpoints accessible when module enabled.""" + response = client.get( + "/api/v1/admin/inventory", + headers=admin_headers, + ) + + # Should succeed + assert response.status_code == 200 + + def test_admin_inventory_forbidden_when_disabled( + self, client, admin_headers, db, test_platform + ): + """Test admin inventory endpoints return 403 when module disabled.""" + # Disable inventory module + test_platform.settings = {"enabled_modules": ["core", "platform-admin"]} + db.commit() + + response = client.get( + "/api/v1/admin/inventory", + headers=admin_headers, + ) + + # Should return 403 + assert response.status_code == 403 + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.modules +class TestModuleDependencyAccess: + """Tests for module dependency enforcement in access control.""" + + def test_marketplace_requires_inventory( + self, client, auth_headers, db, test_platform + ): + """Test marketplace requires inventory to be enabled.""" + # Enable marketplace but disable inventory + test_platform.settings = {"enabled_modules": ["marketplace"]} + db.commit() + + # Due to dependency resolution, inventory should be auto-enabled + response = client.get( + "/api/v1/vendor/inventory", + headers=auth_headers, + ) + + # Should be accessible because marketplace depends on inventory + # The module service should auto-enable inventory + assert response.status_code != 403 or "module" not in response.json().get("message", "").lower() + + def test_disabling_dependency_disables_dependent( + self, client, auth_headers, db, test_platform + ): + """Test that disabling a dependency also affects dependent modules.""" + # First enable both + test_platform.settings = {"enabled_modules": ["inventory", "marketplace"]} + db.commit() + + # Now disable inventory - marketplace should also be affected + test_platform.settings = {"enabled_modules": []} # Only core remains + db.commit() + + # Marketplace should be disabled + response = client.get( + "/api/v1/vendor/marketplace/settings", + headers=auth_headers, + ) + + assert response.status_code == 403