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:
142
alembic/versions/zc2m3n4o5p6q7_add_platform_modules_table.py
Normal file
142
alembic/versions/zc2m3n4o5p6q7_add_platform_modules_table.py
Normal 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")
|
||||||
@@ -61,6 +61,7 @@ from . import (
|
|||||||
media,
|
media,
|
||||||
menu_config,
|
menu_config,
|
||||||
messages,
|
messages,
|
||||||
|
module_config,
|
||||||
modules,
|
modules,
|
||||||
monitoring,
|
monitoring,
|
||||||
notifications,
|
notifications,
|
||||||
@@ -80,8 +81,9 @@ from . import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Import extracted module routers
|
# Import extracted module routers
|
||||||
from app.modules.billing.routes import admin_router as billing_admin_router
|
# NOTE: Import directly from admin.py files to avoid circular imports through __init__.py
|
||||||
from app.modules.inventory.routes import admin_router as inventory_admin_router
|
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_router as orders_admin_router
|
||||||
from app.modules.orders.routes.admin import admin_exceptions_router as orders_exceptions_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
|
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)
|
# Include module management endpoints (super admin only)
|
||||||
router.include_router(modules.router, tags=["admin-modules"])
|
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
|
# User Management
|
||||||
|
|||||||
463
app/api/v1/admin/menu_config.py
Normal file
463
app/api/v1/admin/menu_config.py
Normal 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,
|
||||||
|
)
|
||||||
425
app/api/v1/admin/module_config.py
Normal file
425
app/api/v1/admin/module_config.py
Normal 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,
|
||||||
|
}
|
||||||
54
app/api/v1/vendor/__init__.py
vendored
54
app/api/v1/vendor/__init__.py
vendored
@@ -8,6 +8,23 @@ IMPORTANT:
|
|||||||
- This router is for JSON API endpoints only
|
- This router is for JSON API endpoints only
|
||||||
- HTML page routes are mounted separately in main.py at /vendor/*
|
- HTML page routes are mounted separately in main.py at /vendor/*
|
||||||
- Do NOT include pages.router here - it causes route conflicts
|
- 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
|
from fastapi import APIRouter
|
||||||
@@ -42,6 +59,15 @@ from . import (
|
|||||||
usage,
|
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
|
# Create vendor router
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -67,14 +93,26 @@ router.include_router(onboarding.router, tags=["vendor-onboarding"])
|
|||||||
|
|
||||||
# Business operations (with prefixes: /products/*, /orders/*, etc.)
|
# Business operations (with prefixes: /products/*, /orders/*, etc.)
|
||||||
router.include_router(products.router, tags=["vendor-products"])
|
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(invoices.router, tags=["vendor-invoices"])
|
||||||
router.include_router(customers.router, tags=["vendor-customers"])
|
router.include_router(customers.router, tags=["vendor-customers"])
|
||||||
router.include_router(team.router, tags=["vendor-team"])
|
router.include_router(team.router, tags=["vendor-team"])
|
||||||
router.include_router(inventory.router, tags=["vendor-inventory"])
|
|
||||||
router.include_router(marketplace.router, tags=["vendor-marketplace"])
|
# Include inventory module router (with module access control)
|
||||||
router.include_router(letzshop.router, tags=["vendor-letzshop"])
|
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.)
|
# Services (with prefixes: /payments/*, /media/*, etc.)
|
||||||
router.include_router(payments.router, tags=["vendor-payments"])
|
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(notifications.router, tags=["vendor-notifications"])
|
||||||
router.include_router(messages.router, tags=["vendor-messages"])
|
router.include_router(messages.router, tags=["vendor-messages"])
|
||||||
router.include_router(analytics.router, tags=["vendor-analytics"])
|
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(features.router, tags=["vendor-features"])
|
||||||
router.include_router(usage.router, tags=["vendor-usage"])
|
router.include_router(usage.router, tags=["vendor-usage"])
|
||||||
|
|
||||||
|
|||||||
22
app/modules/analytics/__init__.py
Normal file
22
app/modules/analytics/__init__.py
Normal 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"]
|
||||||
54
app/modules/analytics/definition.py
Normal file
54
app/modules/analytics/definition.py
Normal 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"]
|
||||||
26
app/modules/analytics/routes/__init__.py
Normal file
26
app/modules/analytics/routes/__init__.py
Normal 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}")
|
||||||
25
app/modules/analytics/routes/vendor.py
Normal file
25
app/modules/analytics/routes/vendor.py
Normal 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)
|
||||||
@@ -4,9 +4,25 @@ Billing module route registration.
|
|||||||
|
|
||||||
This module provides functions to register billing routes
|
This module provides functions to register billing routes
|
||||||
with module-based access control.
|
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
|
# Routers are imported on-demand to avoid circular dependencies
|
||||||
from app.modules.billing.routes.vendor import vendor_router
|
# Do NOT add auto-imports here
|
||||||
|
|
||||||
__all__ = ["admin_router", "vendor_router"]
|
__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}")
|
||||||
|
|||||||
22
app/modules/cms/__init__.py
Normal file
22
app/modules/cms/__init__.py
Normal 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"]
|
||||||
66
app/modules/cms/definition.py
Normal file
66
app/modules/cms/definition.py
Normal 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"]
|
||||||
31
app/modules/cms/routes/__init__.py
Normal file
31
app/modules/cms/routes/__init__.py
Normal 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}")
|
||||||
25
app/modules/cms/routes/admin.py
Normal file
25
app/modules/cms/routes/admin.py
Normal 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)
|
||||||
39
app/modules/cms/routes/vendor.py
Normal file
39
app/modules/cms/routes/vendor.py
Normal 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)
|
||||||
22
app/modules/customers/__init__.py
Normal file
22
app/modules/customers/__init__.py
Normal 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"]
|
||||||
62
app/modules/customers/definition.py
Normal file
62
app/modules/customers/definition.py
Normal 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"]
|
||||||
28
app/modules/customers/routes/__init__.py
Normal file
28
app/modules/customers/routes/__init__.py
Normal 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}")
|
||||||
25
app/modules/customers/routes/admin.py
Normal file
25
app/modules/customers/routes/admin.py
Normal 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)
|
||||||
25
app/modules/customers/routes/vendor.py
Normal file
25
app/modules/customers/routes/vendor.py
Normal 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)
|
||||||
21
app/modules/dev_tools/__init__.py
Normal file
21
app/modules/dev_tools/__init__.py
Normal 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"]
|
||||||
35
app/modules/dev_tools/definition.py
Normal file
35
app/modules/dev_tools/definition.py
Normal 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"]
|
||||||
15
app/modules/dev_tools/routes/__init__.py
Normal file
15
app/modules/dev_tools/routes/__init__.py
Normal 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__ = []
|
||||||
@@ -4,9 +4,25 @@ Inventory module route registration.
|
|||||||
|
|
||||||
This module provides functions to register inventory routes
|
This module provides functions to register inventory routes
|
||||||
with module-based access control.
|
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
|
# Routers are imported on-demand to avoid circular dependencies
|
||||||
from app.modules.inventory.routes.vendor import vendor_router
|
# Do NOT add auto-imports here
|
||||||
|
|
||||||
__all__ = ["admin_router", "vendor_router"]
|
__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}")
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ from fastapi import APIRouter, Depends
|
|||||||
|
|
||||||
from app.api.deps import require_module_access
|
from app.api.deps import require_module_access
|
||||||
|
|
||||||
# Import original router
|
# Import original router (direct import to avoid circular dependency)
|
||||||
from app.api.v1.admin import inventory as inventory_routes
|
from app.api.v1.admin.inventory import router as original_router
|
||||||
|
|
||||||
# Create module-aware router
|
# Create module-aware router
|
||||||
admin_router = APIRouter(
|
admin_router = APIRouter(
|
||||||
@@ -22,5 +22,5 @@ admin_router = APIRouter(
|
|||||||
|
|
||||||
# Re-export all routes from the original module with module access control
|
# Re-export all routes from the original module with module access control
|
||||||
# The routes are copied to maintain the same API structure
|
# 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)
|
admin_router.routes.append(route)
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ from fastapi import APIRouter, Depends
|
|||||||
|
|
||||||
from app.api.deps import require_module_access
|
from app.api.deps import require_module_access
|
||||||
|
|
||||||
# Import original router
|
# Import original router (direct import to avoid circular dependency)
|
||||||
from app.api.v1.vendor import inventory as inventory_routes
|
from app.api.v1.vendor.inventory import router as original_router
|
||||||
|
|
||||||
# Create module-aware router
|
# Create module-aware router
|
||||||
vendor_router = APIRouter(
|
vendor_router = APIRouter(
|
||||||
@@ -21,5 +21,5 @@ vendor_router = APIRouter(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Re-export all routes from the original module with module access control
|
# 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)
|
vendor_router.routes.append(route)
|
||||||
|
|||||||
@@ -4,9 +4,31 @@ Marketplace module route registration.
|
|||||||
|
|
||||||
This module provides functions to register marketplace routes
|
This module provides functions to register marketplace routes
|
||||||
with module-based access control.
|
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
|
# Routers are imported on-demand to avoid circular dependencies
|
||||||
from app.modules.marketplace.routes.vendor import vendor_router, vendor_letzshop_router
|
# Do NOT add auto-imports here
|
||||||
|
|
||||||
__all__ = ["admin_router", "admin_letzshop_router", "vendor_router", "vendor_letzshop_router"]
|
__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}")
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ from fastapi import APIRouter, Depends
|
|||||||
|
|
||||||
from app.api.deps import require_module_access
|
from app.api.deps import require_module_access
|
||||||
|
|
||||||
# Import original routers
|
# Import original routers (direct import to avoid circular dependency)
|
||||||
from app.api.v1.admin import marketplace as marketplace_routes
|
from app.api.v1.admin.marketplace import router as marketplace_original_router
|
||||||
from app.api.v1.admin import letzshop as letzshop_routes
|
from app.api.v1.admin.letzshop import router as letzshop_original_router
|
||||||
|
|
||||||
# Create module-aware router for marketplace
|
# Create module-aware router for marketplace
|
||||||
admin_router = APIRouter(
|
admin_router = APIRouter(
|
||||||
@@ -26,7 +26,7 @@ admin_router = APIRouter(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Re-export all routes from the original marketplace module
|
# 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)
|
admin_router.routes.append(route)
|
||||||
|
|
||||||
# Create separate router for letzshop integration
|
# Create separate router for letzshop integration
|
||||||
@@ -35,5 +35,5 @@ admin_letzshop_router = APIRouter(
|
|||||||
dependencies=[Depends(require_module_access("marketplace"))],
|
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)
|
admin_letzshop_router.routes.append(route)
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ from fastapi import APIRouter, Depends
|
|||||||
|
|
||||||
from app.api.deps import require_module_access
|
from app.api.deps import require_module_access
|
||||||
|
|
||||||
# Import original routers
|
# Import original routers (direct import to avoid circular dependency)
|
||||||
from app.api.v1.vendor import marketplace as marketplace_routes
|
from app.api.v1.vendor.marketplace import router as marketplace_original_router
|
||||||
from app.api.v1.vendor import letzshop as letzshop_routes
|
from app.api.v1.vendor.letzshop import router as letzshop_original_router
|
||||||
|
|
||||||
# Create module-aware router for marketplace
|
# Create module-aware router for marketplace
|
||||||
vendor_router = APIRouter(
|
vendor_router = APIRouter(
|
||||||
@@ -26,7 +26,7 @@ vendor_router = APIRouter(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Re-export all routes from the original marketplace module
|
# 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)
|
vendor_router.routes.append(route)
|
||||||
|
|
||||||
# Create separate router for letzshop integration
|
# Create separate router for letzshop integration
|
||||||
@@ -35,5 +35,5 @@ vendor_letzshop_router = APIRouter(
|
|||||||
dependencies=[Depends(require_module_access("marketplace"))],
|
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)
|
vendor_letzshop_router.routes.append(route)
|
||||||
|
|||||||
22
app/modules/messaging/__init__.py
Normal file
22
app/modules/messaging/__init__.py
Normal 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"]
|
||||||
77
app/modules/messaging/definition.py
Normal file
77
app/modules/messaging/definition.py
Normal 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"]
|
||||||
34
app/modules/messaging/routes/__init__.py
Normal file
34
app/modules/messaging/routes/__init__.py
Normal 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}")
|
||||||
39
app/modules/messaging/routes/admin.py
Normal file
39
app/modules/messaging/routes/admin.py
Normal 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)
|
||||||
39
app/modules/messaging/routes/vendor.py
Normal file
39
app/modules/messaging/routes/vendor.py
Normal 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)
|
||||||
25
app/modules/monitoring/__init__.py
Normal file
25
app/modules/monitoring/__init__.py
Normal 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"]
|
||||||
59
app/modules/monitoring/definition.py
Normal file
59
app/modules/monitoring/definition.py
Normal 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"]
|
||||||
26
app/modules/monitoring/routes/__init__.py
Normal file
26
app/modules/monitoring/routes/__init__.py
Normal 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}")
|
||||||
53
app/modules/monitoring/routes/admin.py
Normal file
53
app/modules/monitoring/routes/admin.py
Normal 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)
|
||||||
@@ -4,9 +4,25 @@ Orders module route registration.
|
|||||||
|
|
||||||
This module provides functions to register orders routes
|
This module provides functions to register orders routes
|
||||||
with module-based access control.
|
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
|
# Routers are imported on-demand to avoid circular dependencies
|
||||||
from app.modules.orders.routes.vendor import vendor_router
|
# Do NOT add auto-imports here
|
||||||
|
|
||||||
__all__ = ["admin_router", "vendor_router"]
|
__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}")
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ from fastapi import APIRouter, Depends
|
|||||||
|
|
||||||
from app.api.deps import require_module_access
|
from app.api.deps import require_module_access
|
||||||
|
|
||||||
# Import original routers
|
# Import original routers (direct import to avoid circular dependency)
|
||||||
from app.api.v1.admin import orders as orders_routes
|
from app.api.v1.admin.orders import router as orders_original_router
|
||||||
from app.api.v1.admin import order_item_exceptions as exceptions_routes
|
from app.api.v1.admin.order_item_exceptions import router as exceptions_original_router
|
||||||
|
|
||||||
# Create module-aware router for orders
|
# Create module-aware router for orders
|
||||||
admin_router = APIRouter(
|
admin_router = APIRouter(
|
||||||
@@ -26,7 +26,7 @@ admin_router = APIRouter(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Re-export all routes from the original orders module
|
# 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)
|
admin_router.routes.append(route)
|
||||||
|
|
||||||
# Create separate router for order item exceptions
|
# Create separate router for order item exceptions
|
||||||
@@ -36,5 +36,5 @@ admin_exceptions_router = APIRouter(
|
|||||||
dependencies=[Depends(require_module_access("orders"))],
|
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)
|
admin_exceptions_router.routes.append(route)
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ from fastapi import APIRouter, Depends
|
|||||||
|
|
||||||
from app.api.deps import require_module_access
|
from app.api.deps import require_module_access
|
||||||
|
|
||||||
# Import original routers
|
# Import original routers (direct import to avoid circular dependency)
|
||||||
from app.api.v1.vendor import orders as orders_routes
|
from app.api.v1.vendor.orders import router as orders_original_router
|
||||||
from app.api.v1.vendor import order_item_exceptions as exceptions_routes
|
from app.api.v1.vendor.order_item_exceptions import router as exceptions_original_router
|
||||||
|
|
||||||
# Create module-aware router for orders
|
# Create module-aware router for orders
|
||||||
vendor_router = APIRouter(
|
vendor_router = APIRouter(
|
||||||
@@ -26,7 +26,7 @@ vendor_router = APIRouter(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Re-export all routes from the original orders module
|
# 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)
|
vendor_router.routes.append(route)
|
||||||
|
|
||||||
# Create separate router for order item exceptions
|
# Create separate router for order item exceptions
|
||||||
@@ -35,5 +35,5 @@ vendor_exceptions_router = APIRouter(
|
|||||||
dependencies=[Depends(require_module_access("orders"))],
|
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)
|
vendor_exceptions_router.routes.append(route)
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ from app.modules.billing.definition import billing_module
|
|||||||
from app.modules.inventory.definition import inventory_module
|
from app.modules.inventory.definition import inventory_module
|
||||||
from app.modules.marketplace.definition import marketplace_module
|
from app.modules.marketplace.definition import marketplace_module
|
||||||
from app.modules.orders.definition import orders_module
|
from app.modules.orders.definition import orders_module
|
||||||
|
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,
|
"orders": orders_module,
|
||||||
# Marketplace module - imported from app/modules/marketplace/
|
# Marketplace module - imported from app/modules/marketplace/
|
||||||
"marketplace": marketplace_module,
|
"marketplace": marketplace_module,
|
||||||
"customers": ModuleDefinition(
|
# Customers module - imported from app/modules/customers/
|
||||||
code="customers",
|
"customers": customers_module,
|
||||||
name="Customer Management",
|
# CMS module - imported from app/modules/cms/
|
||||||
description="Customer database, profiles, and segmentation.",
|
"cms": cms_module,
|
||||||
features=[
|
# Analytics module - imported from app/modules/analytics/
|
||||||
"customer_view",
|
"analytics": analytics_module,
|
||||||
"customer_export",
|
# Messaging module - imported from app/modules/messaging/
|
||||||
"customer_profiles",
|
"messaging": messaging_module,
|
||||||
"customer_segmentation",
|
# Dev-Tools module - imported from app/modules/dev_tools/
|
||||||
],
|
"dev-tools": dev_tools_module,
|
||||||
menu_items={
|
# Monitoring module - imported from app/modules/monitoring/
|
||||||
FrontendType.ADMIN: [
|
"monitoring": monitoring_module,
|
||||||
"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: [],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ Module service for platform module operations.
|
|||||||
Provides methods to check module enablement, get enabled modules,
|
Provides methods to check module enablement, get enabled modules,
|
||||||
and filter menu items based on module configuration.
|
and filter menu items based on module configuration.
|
||||||
|
|
||||||
Module configuration is stored in Platform.settings["enabled_modules"].
|
Module configuration can be stored in two places:
|
||||||
If not configured, all modules are enabled (backwards compatibility).
|
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
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
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.admin_menu_config import FrontendType
|
||||||
from models.database.platform import Platform
|
from models.database.platform import Platform
|
||||||
|
from models.database.platform_module import PlatformModule
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -34,11 +39,20 @@ class ModuleService:
|
|||||||
Handles module enablement checking, module listing, and menu item filtering
|
Handles module enablement checking, module listing, and menu item filtering
|
||||||
based on enabled modules.
|
based on enabled modules.
|
||||||
|
|
||||||
Module configuration is stored in Platform.settings["enabled_modules"]:
|
Module configuration is stored in two places (with fallback):
|
||||||
- If key exists: Only listed modules (plus core) are enabled
|
1. PlatformModule junction table (preferred, auditable)
|
||||||
- If key missing: All modules are enabled (backwards compatibility)
|
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"],
|
"enabled_modules": ["core", "billing", "inventory", "orders"],
|
||||||
"module_config": {
|
"module_config": {
|
||||||
@@ -119,10 +133,13 @@ class ModuleService:
|
|||||||
platform_id: int,
|
platform_id: int,
|
||||||
) -> set[str]:
|
) -> set[str]:
|
||||||
"""
|
"""
|
||||||
Get enabled module codes from platform settings.
|
Get enabled module codes for a platform.
|
||||||
|
|
||||||
Internal method that reads Platform.settings["enabled_modules"].
|
Checks two sources with fallback:
|
||||||
If not configured, returns all module codes (backwards compatibility).
|
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.
|
Always includes core modules.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -137,22 +154,102 @@ class ModuleService:
|
|||||||
logger.warning(f"Platform {platform_id} not found, returning all modules")
|
logger.warning(f"Platform {platform_id} not found, returning all modules")
|
||||||
return set(MODULES.keys())
|
return set(MODULES.keys())
|
||||||
|
|
||||||
settings = platform.settings or {}
|
# Try junction table first (preferred)
|
||||||
enabled_modules = settings.get("enabled_modules")
|
platform_modules = (
|
||||||
|
db.query(PlatformModule)
|
||||||
|
.filter(PlatformModule.platform_id == platform_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
# If not configured, enable all modules (backwards compatibility)
|
if platform_modules:
|
||||||
if enabled_modules is None:
|
# Use junction table data
|
||||||
return set(MODULES.keys())
|
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
|
# Always include core modules
|
||||||
core_codes = get_core_module_codes()
|
core_codes = get_core_module_codes()
|
||||||
enabled_set = set(enabled_modules) | core_codes
|
enabled_set = enabled_set | core_codes
|
||||||
|
|
||||||
# Resolve dependencies - add required modules
|
# Resolve dependencies - add required modules
|
||||||
enabled_set = self._resolve_dependencies(enabled_set)
|
enabled_set = self._resolve_dependencies(enabled_set)
|
||||||
|
|
||||||
return 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]:
|
def _resolve_dependencies(self, enabled_codes: set[str]) -> set[str]:
|
||||||
"""
|
"""
|
||||||
Resolve module dependencies by adding required modules.
|
Resolve module dependencies by adding required modules.
|
||||||
@@ -283,6 +380,10 @@ class ModuleService:
|
|||||||
"""
|
"""
|
||||||
Get module-specific configuration for a platform.
|
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:
|
Args:
|
||||||
db: Database session
|
db: Database session
|
||||||
platform_id: Platform ID
|
platform_id: Platform ID
|
||||||
@@ -291,6 +392,20 @@ class ModuleService:
|
|||||||
Returns:
|
Returns:
|
||||||
Module configuration dict (empty if not configured)
|
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()
|
platform = db.query(Platform).filter(Platform.id == platform_id).first()
|
||||||
if not platform:
|
if not platform:
|
||||||
return {}
|
return {}
|
||||||
@@ -299,22 +414,80 @@ class ModuleService:
|
|||||||
module_configs = settings.get("module_config", {})
|
module_configs = settings.get("module_config", {})
|
||||||
return module_configs.get(module_code, {})
|
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(
|
def set_enabled_modules(
|
||||||
self,
|
self,
|
||||||
db: Session,
|
db: Session,
|
||||||
platform_id: int,
|
platform_id: int,
|
||||||
module_codes: list[str],
|
module_codes: list[str],
|
||||||
|
user_id: int | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Set the enabled modules for a platform.
|
Set the enabled modules for a platform.
|
||||||
|
|
||||||
Core modules are automatically included.
|
Core modules are automatically included.
|
||||||
Dependencies are automatically resolved.
|
Dependencies are automatically resolved.
|
||||||
|
Uses junction table for auditability.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database session
|
db: Database session
|
||||||
platform_id: Platform ID
|
platform_id: Platform ID
|
||||||
module_codes: List of module codes to enable
|
module_codes: List of module codes to enable
|
||||||
|
user_id: ID of user making the change (for audit)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if successful, False if platform not found
|
True if successful, False if platform not found
|
||||||
@@ -338,10 +511,44 @@ class ModuleService:
|
|||||||
# Resolve dependencies
|
# Resolve dependencies
|
||||||
enabled_set = self._resolve_dependencies(enabled_set)
|
enabled_set = self._resolve_dependencies(enabled_set)
|
||||||
|
|
||||||
# Update platform settings
|
now = datetime.now(timezone.utc)
|
||||||
settings = platform.settings or {}
|
|
||||||
settings["enabled_modules"] = list(enabled_set)
|
# Update junction table for all modules
|
||||||
platform.settings = settings
|
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(
|
logger.info(
|
||||||
f"Updated enabled modules for platform {platform_id}: {sorted(enabled_set)}"
|
f"Updated enabled modules for platform {platform_id}: {sorted(enabled_set)}"
|
||||||
@@ -353,16 +560,19 @@ class ModuleService:
|
|||||||
db: Session,
|
db: Session,
|
||||||
platform_id: int,
|
platform_id: int,
|
||||||
module_code: str,
|
module_code: str,
|
||||||
|
user_id: int | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Enable a single module for a platform.
|
Enable a single module for a platform.
|
||||||
|
|
||||||
Also enables required dependencies.
|
Also enables required dependencies.
|
||||||
|
Uses junction table for auditability when available.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database session
|
db: Database session
|
||||||
platform_id: Platform ID
|
platform_id: Platform ID
|
||||||
module_code: Module code to enable
|
module_code: Module code to enable
|
||||||
|
user_id: ID of user enabling the module (for audit)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if successful
|
True if successful
|
||||||
@@ -376,15 +586,45 @@ class ModuleService:
|
|||||||
logger.error(f"Platform {platform_id} not found")
|
logger.error(f"Platform {platform_id} not found")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
settings = platform.settings or {}
|
# Migrate JSON settings to junction table if needed
|
||||||
enabled = set(settings.get("enabled_modules", list(MODULES.keys())))
|
self._migrate_json_to_junction_table(db, platform_id, user_id)
|
||||||
enabled.add(module_code)
|
|
||||||
|
|
||||||
# Resolve dependencies
|
now = datetime.now(timezone.utc)
|
||||||
enabled = self._resolve_dependencies(enabled)
|
|
||||||
|
|
||||||
settings["enabled_modules"] = list(enabled)
|
# Enable this module and its dependencies
|
||||||
platform.settings = settings
|
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}")
|
logger.info(f"Enabled module '{module_code}' for platform {platform_id}")
|
||||||
return True
|
return True
|
||||||
@@ -394,17 +634,20 @@ class ModuleService:
|
|||||||
db: Session,
|
db: Session,
|
||||||
platform_id: int,
|
platform_id: int,
|
||||||
module_code: str,
|
module_code: str,
|
||||||
|
user_id: int | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Disable a single module for a platform.
|
Disable a single module for a platform.
|
||||||
|
|
||||||
Core modules cannot be disabled.
|
Core modules cannot be disabled.
|
||||||
Also disables modules that depend on this one.
|
Also disables modules that depend on this one.
|
||||||
|
Uses junction table for auditability when available.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database session
|
db: Database session
|
||||||
platform_id: Platform ID
|
platform_id: Platform ID
|
||||||
module_code: Module code to disable
|
module_code: Module code to disable
|
||||||
|
user_id: ID of user disabling the module (for audit)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if successful, False if core or not found
|
True if successful, False if core or not found
|
||||||
@@ -423,23 +666,48 @@ class ModuleService:
|
|||||||
logger.error(f"Platform {platform_id} not found")
|
logger.error(f"Platform {platform_id} not found")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
settings = platform.settings or {}
|
# Migrate JSON settings to junction table if needed
|
||||||
enabled = set(settings.get("enabled_modules", list(MODULES.keys())))
|
self._migrate_json_to_junction_table(db, platform_id, user_id)
|
||||||
|
|
||||||
# Remove this module
|
now = datetime.now(timezone.utc)
|
||||||
enabled.discard(module_code)
|
|
||||||
|
|
||||||
# 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)
|
dependents = self._get_dependent_modules(module_code)
|
||||||
for dependent in dependents:
|
modules_to_disable.update(dependents)
|
||||||
if dependent in enabled:
|
|
||||||
enabled.discard(dependent)
|
|
||||||
logger.info(
|
|
||||||
f"Also disabled '{dependent}' (depends on '{module_code}')"
|
|
||||||
)
|
|
||||||
|
|
||||||
settings["enabled_modules"] = list(enabled)
|
for code in modules_to_disable:
|
||||||
platform.settings = settings
|
# 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}")
|
logger.info(f"Disabled module '{module_code}' for platform {platform_id}")
|
||||||
return True
|
return True
|
||||||
|
|||||||
148
app/templates/admin/module-config.html
Normal file
148
app/templates/admin/module-config.html
Normal 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 %}
|
||||||
@@ -8,6 +8,7 @@ from .admin import (
|
|||||||
AdminSetting,
|
AdminSetting,
|
||||||
PlatformAlert,
|
PlatformAlert,
|
||||||
)
|
)
|
||||||
|
from .admin_menu_config import AdminMenuConfig, FrontendType, MANDATORY_MENU_ITEMS
|
||||||
from .admin_platform import AdminPlatform
|
from .admin_platform import AdminPlatform
|
||||||
from .architecture_scan import (
|
from .architecture_scan import (
|
||||||
ArchitectureScan,
|
ArchitectureScan,
|
||||||
@@ -19,6 +20,7 @@ from .base import Base
|
|||||||
from .company import Company
|
from .company import Company
|
||||||
from .content_page import ContentPage
|
from .content_page import ContentPage
|
||||||
from .platform import Platform
|
from .platform import Platform
|
||||||
|
from .platform_module import PlatformModule
|
||||||
from .vendor_platform import VendorPlatform
|
from .vendor_platform import VendorPlatform
|
||||||
from .customer import Customer, CustomerAddress
|
from .customer import Customer, CustomerAddress
|
||||||
from .password_reset_token import PasswordResetToken
|
from .password_reset_token import PasswordResetToken
|
||||||
@@ -83,9 +85,12 @@ from .vendor_theme import VendorTheme
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
# Admin-specific models
|
# Admin-specific models
|
||||||
"AdminAuditLog",
|
"AdminAuditLog",
|
||||||
|
"AdminMenuConfig",
|
||||||
|
"FrontendType",
|
||||||
"AdminNotification",
|
"AdminNotification",
|
||||||
"AdminPlatform",
|
"AdminPlatform",
|
||||||
"AdminSetting",
|
"AdminSetting",
|
||||||
|
"MANDATORY_MENU_ITEMS",
|
||||||
"PlatformAlert",
|
"PlatformAlert",
|
||||||
"AdminSession",
|
"AdminSession",
|
||||||
# Architecture/Code Quality
|
# Architecture/Code Quality
|
||||||
@@ -112,6 +117,7 @@ __all__ = [
|
|||||||
"ContentPage",
|
"ContentPage",
|
||||||
# Platform
|
# Platform
|
||||||
"Platform",
|
"Platform",
|
||||||
|
"PlatformModule",
|
||||||
"VendorPlatform",
|
"VendorPlatform",
|
||||||
# Customer & Auth
|
# Customer & Auth
|
||||||
"Customer",
|
"Customer",
|
||||||
|
|||||||
@@ -199,6 +199,20 @@ class Platform(Base, TimestampMixin):
|
|||||||
cascade="all, delete-orphan",
|
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
|
# Indexes
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
|
|||||||
162
models/database/platform_module.py
Normal file
162
models/database/platform_module.py
Normal 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})>"
|
||||||
149
static/admin/js/module-config.js
Normal file
149
static/admin/js/module-config.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
187
static/admin/js/my-menu-config.js
Normal file
187
static/admin/js/my-menu-config.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
212
static/admin/js/platform-menu-config.js
Normal file
212
static/admin/js/platform-menu-config.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,12 +4,9 @@
|
|||||||
const moduleConfigLog = window.LogConfig?.loggers?.moduleConfig || window.LogConfig?.createLogger?.('moduleConfig') || console;
|
const moduleConfigLog = window.LogConfig?.loggers?.moduleConfig || window.LogConfig?.createLogger?.('moduleConfig') || console;
|
||||||
|
|
||||||
function adminPlatformModules(platformCode) {
|
function adminPlatformModules(platformCode) {
|
||||||
// Get base data with safety check for standalone usage
|
|
||||||
const baseData = typeof data === 'function' ? data() : {};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Inherit base layout functionality from init-alpine.js
|
// Inherit base layout functionality from init-alpine.js
|
||||||
...baseData,
|
...data(),
|
||||||
|
|
||||||
// Page-specific state
|
// Page-specific state
|
||||||
currentPage: 'platforms',
|
currentPage: 'platforms',
|
||||||
@@ -62,6 +59,13 @@ function adminPlatformModules(platformCode) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async init() {
|
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 MODULES PAGE INITIALIZING ===');
|
||||||
moduleConfigLog.info('Platform code:', this.platformCode);
|
moduleConfigLog.info('Platform code:', this.platformCode);
|
||||||
|
|
||||||
|
|||||||
159
tests/fixtures/module_fixtures.py
vendored
Normal file
159
tests/fixtures/module_fixtures.py
vendored
Normal 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
|
||||||
328
tests/integration/api/v1/admin/test_modules.py
Normal file
328
tests/integration/api/v1/admin/test_modules.py
Normal 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"
|
||||||
2
tests/integration/api/v1/modules/__init__.py
Normal file
2
tests/integration/api/v1/modules/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# tests/integration/api/v1/modules/__init__.py
|
||||||
|
"""Integration tests for module access control."""
|
||||||
253
tests/integration/api/v1/modules/test_module_access.py
Normal file
253
tests/integration/api/v1/modules/test_module_access.py
Normal 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
|
||||||
Reference in New Issue
Block a user