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 <noreply@anthropic.com>
This commit is contained in:
2026-01-26 18:19:00 +01:00
parent f29f1113cd
commit c419090531
55 changed files with 4059 additions and 206 deletions

View File

@@ -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")

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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,
}

View File

@@ -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"])

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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}")

View File

@@ -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)

View File

@@ -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}")

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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}")

View File

@@ -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)

View File

@@ -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)

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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}")

View File

@@ -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)

View File

@@ -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)

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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__ = []

View File

@@ -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}")

View File

@@ -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)

View File

@@ -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)

View File

@@ -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}")

View File

@@ -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)

View File

@@ -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)

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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}")

View File

@@ -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)

View File

@@ -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)

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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}")

View File

@@ -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)

View File

@@ -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}")

View File

@@ -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)

View File

@@ -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)

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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') }}
<!-- Module Info -->
<div class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="moduleInfo?.module_name || 'Loading...'"></h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
Configure settings for this module on <span x-text="platformName" class="font-medium"></span>.
</p>
</div>
<span class="px-3 py-1 text-sm font-medium rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200" x-text="moduleInfo?.module_code?.toUpperCase()"></span>
</div>
</div>
<!-- Loading State -->
<div x-show="loading" class="flex items-center justify-center py-12 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<span x-html="$icon('refresh', 'w-8 h-8 animate-spin text-purple-600')"></span>
<span class="ml-3 text-gray-500 dark:text-gray-400">Loading configuration...</span>
</div>
<!-- Configuration Form -->
<div x-show="!loading" class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-200">Configuration Options</h3>
</div>
<!-- Config Fields -->
<div class="p-4 space-y-6">
<template x-if="moduleInfo?.schema_info?.length > 0">
<div class="space-y-6">
<template x-for="field in moduleInfo.schema_info" :key="field.key">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-2" x-text="field.label"></label>
<!-- Boolean field -->
<template x-if="field.type === 'boolean'">
<div class="flex items-center">
<button
@click="config[field.key] = !config[field.key]"
:class="{
'bg-purple-600': config[field.key],
'bg-gray-200 dark:bg-gray-600': !config[field.key]
}"
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2"
role="switch"
:aria-checked="config[field.key]"
>
<span
:class="{
'translate-x-5': config[field.key],
'translate-x-0': !config[field.key]
}"
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
></span>
</button>
<span class="ml-3 text-sm text-gray-500 dark:text-gray-400" x-text="config[field.key] ? 'Enabled' : 'Disabled'"></span>
</div>
</template>
<!-- Number field (dynamic Alpine.js template - cannot use static macro) --> {# noqa: FE-008 #}
<template x-if="field.type === 'number'">
<input
type="number"
x-model.number="config[field.key]"
:min="field.min"
:max="field.max"
class="block w-full max-w-xs px-3 py-2 text-sm leading-5 text-gray-700 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:outline-none focus:ring focus:ring-purple-400"
>
</template>
<!-- String field -->
<template x-if="field.type === 'string'">
<input
type="text"
x-model="config[field.key]"
class="block w-full max-w-md px-3 py-2 text-sm leading-5 text-gray-700 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:outline-none focus:ring focus:ring-purple-400"
>
</template>
<!-- Select field -->
<template x-if="field.type === 'select'">
<select
x-model="config[field.key]"
class="block w-full max-w-xs px-3 py-2 text-sm leading-5 text-gray-700 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:outline-none focus:ring focus:ring-purple-400"
>
<template x-for="option in field.options" :key="option">
<option :value="option" x-text="option"></option>
</template>
</select>
</template>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400" x-text="field.description"></p>
</div>
</template>
</div>
</template>
<!-- No config options -->
<template x-if="!moduleInfo?.schema_info?.length">
<div class="text-center py-8">
<span x-html="$icon('cog', 'w-12 h-12 mx-auto text-gray-400')"></span>
<p class="mt-4 text-gray-500 dark:text-gray-400">No configuration options available for this module.</p>
</div>
</template>
</div>
<!-- Actions -->
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-700/50 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
<button
@click="resetToDefaults()"
:disabled="saving"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600 disabled:opacity-50"
>
<span x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
Reset to Defaults
</button>
<button
@click="saveConfig()"
:disabled="saving"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 focus:outline-none focus:ring focus:ring-purple-400 disabled:opacity-50"
>
<span x-show="saving" x-html="$icon('refresh', 'w-4 h-4 mr-2 animate-spin')"></span>
<span x-show="!saving" x-html="$icon('check', 'w-4 h-4 mr-2')"></span>
<span x-text="saving ? 'Saving...' : 'Save Configuration'"></span>
</button>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', path='admin/js/module-config.js') }}"></script>
{% endblock %}

View File

@@ -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",

View File

@@ -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
# ========================================================================

View File

@@ -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"<PlatformModule(platform_id={self.platform_id}, module='{self.module_code}', {status})>"

View File

@@ -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;
}
}
};
}

View File

@@ -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;
}
}
};
}

View File

@@ -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;
}
}
};
}

View File

@@ -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);

159
tests/fixtures/module_fixtures.py vendored Normal file
View File

@@ -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

View File

@@ -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"

View File

@@ -0,0 +1,2 @@
# tests/integration/api/v1/modules/__init__.py
"""Integration tests for module access control."""

View File

@@ -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