refactor: complete module-driven architecture migration

This commit completes the migration to a fully module-driven architecture:

## Models Migration
- Moved all domain models from models/database/ to their respective modules:
  - tenancy: User, Admin, Vendor, Company, Platform, VendorDomain, etc.
  - cms: MediaFile, VendorTheme
  - messaging: Email, VendorEmailSettings, VendorEmailTemplate
  - core: AdminMenuConfig
- models/database/ now only contains Base and TimestampMixin (infrastructure)

## Schemas Migration
- Moved all domain schemas from models/schema/ to their respective modules:
  - tenancy: company, vendor, admin, team, vendor_domain
  - cms: media, image, vendor_theme
  - messaging: email
- models/schema/ now only contains base.py and auth.py (infrastructure)

## Routes Migration
- Moved admin routes from app/api/v1/admin/ to modules:
  - menu_config.py -> core module
  - modules.py -> tenancy module
  - module_config.py -> tenancy module
- app/api/v1/admin/ now only aggregates auto-discovered module routes

## Menu System
- Implemented module-driven menu system with MenuDiscoveryService
- Extended FrontendType enum: PLATFORM, ADMIN, VENDOR, STOREFRONT
- Added MenuItemDefinition and MenuSectionDefinition dataclasses
- Each module now defines its own menu items in definition.py
- MenuService integrates with MenuDiscoveryService for template rendering

## Documentation
- Updated docs/architecture/models-structure.md
- Updated docs/architecture/menu-management.md
- Updated architecture validation rules for new exceptions

## Architecture Validation
- Updated MOD-019 rule to allow base.py in models/schema/
- Created core module exceptions.py and schemas/ directory
- All validation errors resolved (only warnings remain)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 21:02:56 +01:00
parent 09d7d282c6
commit d7a0ff8818
307 changed files with 5536 additions and 3826 deletions

View File

@@ -18,6 +18,12 @@ from app.modules.core.services.admin_settings_service import (
from app.modules.core.services.auth_service import AuthService, auth_service
from app.modules.core.services.image_service import ImageService, image_service
from app.modules.core.services.menu_service import MenuItemConfig, MenuService, menu_service
from app.modules.core.services.menu_discovery_service import (
DiscoveredMenuItem,
DiscoveredMenuSection,
MenuDiscoveryService,
menu_discovery_service,
)
from app.modules.core.services.platform_settings_service import (
PlatformSettingsService,
platform_settings_service,
@@ -34,10 +40,15 @@ __all__ = [
# Auth
"AuthService",
"auth_service",
# Menu
# Menu (legacy)
"MenuService",
"MenuItemConfig",
"menu_service",
# Menu Discovery (module-driven)
"MenuDiscoveryService",
"DiscoveredMenuItem",
"DiscoveredMenuSection",
"menu_discovery_service",
# Image
"ImageService",
"image_service",

View File

@@ -21,8 +21,8 @@ from app.exceptions import (
ValidationException,
)
from app.modules.tenancy.exceptions import AdminOperationException
from models.database.admin import AdminSetting
from models.schema.admin import (
from app.modules.tenancy.models import AdminSetting
from app.modules.tenancy.schemas.admin import (
AdminSettingCreate,
AdminSettingResponse,
AdminSettingUpdate,

View File

@@ -19,8 +19,8 @@ from sqlalchemy.orm import Session
from app.modules.tenancy.exceptions import InvalidCredentialsException, UserNotActiveException
from middleware.auth import AuthManager
from models.database.user import User
from models.database.vendor import Vendor, VendorUser
from app.modules.tenancy.models import User
from app.modules.tenancy.models import Vendor, VendorUser
from models.schema.auth import UserLogin
logger = logging.getLogger(__name__)

View File

@@ -0,0 +1,446 @@
# app/modules/core/services/menu_discovery_service.py
"""
Menu Discovery Service - Discovers and aggregates menu items from all modules.
This service implements the module-driven menu system where each module
defines its own menu items through MenuSectionDefinition and MenuItemDefinition
in its definition.py file.
Key Features:
- Discovers menu definitions from all loaded modules
- Filters by module enablement (disabled modules = hidden menus)
- Respects user/platform visibility preferences (AdminMenuConfig)
- Supports permission-based filtering
- Enforces mandatory item visibility
Usage:
from app.modules.core.services.menu_discovery_service import menu_discovery_service
# Get complete menu for admin frontend
menu = menu_discovery_service.get_menu_for_frontend(
db,
FrontendType.ADMIN,
platform_id=1,
user=current_user
)
# Get flat list of all menu items for configuration UI
items = menu_discovery_service.get_all_menu_items(FrontendType.ADMIN)
"""
import logging
from copy import deepcopy
from dataclasses import dataclass, field
from sqlalchemy.orm import Session
from app.modules.base import MenuItemDefinition, MenuSectionDefinition
from app.modules.enums import FrontendType
from app.modules.service import module_service
logger = logging.getLogger(__name__)
@dataclass
class DiscoveredMenuItem:
"""
A menu item discovered from a module, enriched with runtime info.
Extends MenuItemDefinition with runtime context like visibility status,
module enablement, and resolved route.
"""
id: str
label_key: str
icon: str
route: str
order: int
is_mandatory: bool
requires_permission: str | None
badge_source: str | None
is_super_admin_only: bool
# Runtime enrichment
module_code: str
section_id: str
section_label_key: str | None
section_order: int
is_visible: bool = True
is_module_enabled: bool = True
@dataclass
class DiscoveredMenuSection:
"""
A menu section discovered from modules, with aggregated items.
Multiple modules may contribute items to the same section.
"""
id: str
label_key: str | None
icon: str | None
order: int
is_super_admin_only: bool
is_collapsible: bool
items: list[DiscoveredMenuItem] = field(default_factory=list)
class MenuDiscoveryService:
"""
Service to discover and aggregate menu items from all enabled modules.
This service:
1. Collects menu definitions from all module definition.py files
2. Filters by module enablement for the platform
3. Applies user/platform visibility preferences
4. Supports permission-based filtering
5. Returns sorted, renderable menu structures
"""
def discover_all_menus(self) -> dict[FrontendType, list[MenuSectionDefinition]]:
"""
Discover all menu definitions from all loaded modules.
Returns:
Dict mapping FrontendType to list of MenuSectionDefinition
from all modules (not filtered by enablement).
"""
from app.modules.registry import MODULES
all_menus: dict[FrontendType, list[MenuSectionDefinition]] = {
ft: [] for ft in FrontendType
}
for module_code, module_def in MODULES.items():
for frontend_type, sections in module_def.menus.items():
all_menus[frontend_type].extend(deepcopy(sections))
return all_menus
def get_menu_sections_for_frontend(
self,
db: Session,
frontend_type: FrontendType,
platform_id: int | None = None,
) -> list[DiscoveredMenuSection]:
"""
Get aggregated menu sections for a frontend type.
Filters by module enablement if platform_id is provided.
Does NOT apply user visibility preferences (use get_menu_for_frontend for that).
Args:
db: Database session
frontend_type: Frontend type to get menus for
platform_id: Platform ID for module enablement filtering
Returns:
List of DiscoveredMenuSection sorted by order
"""
from app.modules.registry import MODULES
# Track sections by ID for aggregation
sections_map: dict[str, DiscoveredMenuSection] = {}
for module_code, module_def in MODULES.items():
# Check if module is enabled for this platform
is_module_enabled = True
if platform_id:
is_module_enabled = module_service.is_module_enabled(
db, platform_id, module_code
)
# Get menu sections for this frontend type
module_sections = module_def.menus.get(frontend_type, [])
for section in module_sections:
# Get or create section entry
if section.id not in sections_map:
sections_map[section.id] = DiscoveredMenuSection(
id=section.id,
label_key=section.label_key,
icon=section.icon,
order=section.order,
is_super_admin_only=section.is_super_admin_only,
is_collapsible=section.is_collapsible,
items=[],
)
# Add items from this module to the section
for item in section.items:
discovered_item = DiscoveredMenuItem(
id=item.id,
label_key=item.label_key,
icon=item.icon,
route=item.route,
order=item.order,
is_mandatory=item.is_mandatory,
requires_permission=item.requires_permission,
badge_source=item.badge_source,
is_super_admin_only=item.is_super_admin_only,
module_code=module_code,
section_id=section.id,
section_label_key=section.label_key,
section_order=section.order,
is_module_enabled=is_module_enabled,
)
sections_map[section.id].items.append(discovered_item)
# Sort sections by order
sorted_sections = sorted(sections_map.values(), key=lambda s: s.order)
# Sort items within each section
for section in sorted_sections:
section.items.sort(key=lambda i: i.order)
return sorted_sections
def get_menu_for_frontend(
self,
db: Session,
frontend_type: FrontendType,
platform_id: int | None = None,
user_id: int | None = None,
is_super_admin: bool = False,
vendor_code: str | None = None,
) -> list[DiscoveredMenuSection]:
"""
Get filtered menu structure for frontend rendering.
Applies all filters:
1. Module enablement (disabled modules = hidden items)
2. Visibility configuration (AdminMenuConfig preferences)
3. Super admin status (hides super_admin_only items for non-super-admins)
4. Permission requirements (future: filter by user permissions)
Args:
db: Database session
frontend_type: Frontend type (ADMIN, VENDOR, etc.)
platform_id: Platform ID for module enablement and visibility
user_id: User ID for user-specific visibility (super admins only)
is_super_admin: Whether the user is a super admin
vendor_code: Vendor code for route placeholder replacement
Returns:
List of DiscoveredMenuSection with filtered and sorted items
"""
# Get all sections with module enablement filtering
sections = self.get_menu_sections_for_frontend(db, frontend_type, platform_id)
# Get visibility configuration
visible_item_ids = self._get_visible_item_ids(
db, frontend_type, platform_id, user_id
)
# Filter sections and items
filtered_sections = []
for section in sections:
# Skip super_admin_only sections for non-super-admins
if section.is_super_admin_only and not is_super_admin:
continue
# Filter items
filtered_items = []
for item in section.items:
# Skip if module is disabled
if not item.is_module_enabled:
continue
# Skip super_admin_only items for non-super-admins
if item.is_super_admin_only and not is_super_admin:
continue
# Apply visibility (mandatory items always visible)
if visible_item_ids is not None and not item.is_mandatory:
if item.id not in visible_item_ids:
continue
# Resolve route placeholders
if vendor_code and "{vendor_code}" in item.route:
item.route = item.route.replace("{vendor_code}", vendor_code)
item.is_visible = True
filtered_items.append(item)
# Only include section if it has visible items
if filtered_items:
section.items = filtered_items
filtered_sections.append(section)
return filtered_sections
def _get_visible_item_ids(
self,
db: Session,
frontend_type: FrontendType,
platform_id: int | None = None,
user_id: int | None = None,
) -> set[str] | None:
"""
Get set of visible menu item IDs from AdminMenuConfig.
Returns:
Set of visible item IDs, or None if no config exists (default all visible)
"""
from app.modules.core.models import AdminMenuConfig
if not platform_id and not user_id:
return None
query = db.query(AdminMenuConfig).filter(
AdminMenuConfig.frontend_type == frontend_type,
)
if platform_id:
query = query.filter(AdminMenuConfig.platform_id == platform_id)
elif user_id:
query = query.filter(AdminMenuConfig.user_id == user_id)
configs = query.all()
if not configs:
return None # No config = all visible by default
return {c.menu_item_id for c in configs if c.is_visible}
def get_all_menu_items(
self,
frontend_type: FrontendType,
) -> list[DiscoveredMenuItem]:
"""
Get flat list of all menu items for a frontend type.
Useful for configuration UI where you need to show all possible items.
Does NOT filter by module enablement or visibility.
Args:
frontend_type: Frontend type to get items for
Returns:
Flat list of DiscoveredMenuItem from all modules
"""
from app.modules.registry import MODULES
items = []
for module_code, module_def in MODULES.items():
for section in module_def.menus.get(frontend_type, []):
for item in section.items:
discovered_item = DiscoveredMenuItem(
id=item.id,
label_key=item.label_key,
icon=item.icon,
route=item.route,
order=item.order,
is_mandatory=item.is_mandatory,
requires_permission=item.requires_permission,
badge_source=item.badge_source,
is_super_admin_only=item.is_super_admin_only,
module_code=module_code,
section_id=section.id,
section_label_key=section.label_key,
section_order=section.order,
)
items.append(discovered_item)
return sorted(items, key=lambda i: (i.section_order, i.order))
def get_mandatory_item_ids(
self,
frontend_type: FrontendType,
) -> set[str]:
"""
Get all mandatory menu item IDs for a frontend type.
Mandatory items cannot be hidden by users.
Args:
frontend_type: Frontend type to get mandatory items for
Returns:
Set of menu item IDs marked as is_mandatory=True
"""
from app.modules.registry import MODULES
mandatory_ids = set()
for module_def in MODULES.values():
for section in module_def.menus.get(frontend_type, []):
for item in section.items:
if item.is_mandatory:
mandatory_ids.add(item.id)
return mandatory_ids
def get_menu_item_module(
self,
menu_item_id: str,
frontend_type: FrontendType,
) -> str | None:
"""
Get the module code that provides a specific menu item.
Args:
menu_item_id: Menu item ID to look up
frontend_type: Frontend type to search in
Returns:
Module code, or None if not found
"""
from app.modules.registry import MODULES
for module_code, module_def in MODULES.items():
for section in module_def.menus.get(frontend_type, []):
for item in section.items:
if item.id == menu_item_id:
return module_code
return None
def menu_to_legacy_format(
self,
sections: list[DiscoveredMenuSection],
) -> dict:
"""
Convert discovered menu sections to legacy registry format.
This allows gradual migration by using new discovery with old rendering.
Args:
sections: List of DiscoveredMenuSection
Returns:
Dict in ADMIN_MENU_REGISTRY/VENDOR_MENU_REGISTRY format
"""
return {
"sections": [
{
"id": section.id,
"label": section.label_key, # Note: key not resolved
"super_admin_only": section.is_super_admin_only,
"items": [
{
"id": item.id,
"label": item.label_key, # Note: key not resolved
"icon": item.icon,
"url": item.route,
"super_admin_only": item.is_super_admin_only,
}
for item in section.items
],
}
for section in sections
]
}
# Singleton instance
menu_discovery_service = MenuDiscoveryService()
__all__ = [
"menu_discovery_service",
"MenuDiscoveryService",
"DiscoveredMenuItem",
"DiscoveredMenuSection",
]

View File

@@ -42,11 +42,9 @@ from app.config.menu_registry import (
is_super_admin_only_item,
)
from app.modules.service import module_service
from models.database.admin_menu_config import (
AdminMenuConfig,
FrontendType,
MANDATORY_MENU_ITEMS,
)
from app.modules.core.models import AdminMenuConfig, MANDATORY_MENU_ITEMS
from app.modules.core.services.menu_discovery_service import menu_discovery_service
from app.modules.enums import FrontendType
logger = logging.getLogger(__name__)
@@ -236,10 +234,13 @@ class MenuService:
platform_id: int | None = None,
user_id: int | None = None,
is_super_admin: bool = False,
vendor_code: str | None = None,
) -> dict:
"""
Get filtered menu structure for frontend rendering.
Uses MenuDiscoveryService to aggregate menus from all enabled modules.
Filters by:
1. Module enablement (items from disabled modules are removed)
2. Visibility configuration
@@ -251,40 +252,23 @@ class MenuService:
platform_id: Platform ID (for platform admins and vendors)
user_id: User ID (for super admins only)
is_super_admin: Whether user is super admin (affects admin-only sections)
vendor_code: Vendor code for URL placeholder replacement (vendor frontend)
Returns:
Filtered menu structure ready for rendering
"""
registry = (
ADMIN_MENU_REGISTRY if frontend_type == FrontendType.ADMIN else VENDOR_MENU_REGISTRY
# Use the module-driven discovery service to get filtered menu
sections = menu_discovery_service.get_menu_for_frontend(
db=db,
frontend_type=frontend_type,
platform_id=platform_id,
user_id=user_id,
is_super_admin=is_super_admin,
vendor_code=vendor_code,
)
visible_items = self.get_visible_menu_items(db, frontend_type, platform_id, user_id)
# Deep copy to avoid modifying the registry
filtered_menu = deepcopy(registry)
filtered_sections = []
for section in filtered_menu["sections"]:
# Skip super_admin_only sections if user is not super admin
if section.get("super_admin_only") and not is_super_admin:
continue
# Filter items to only visible ones
# Also skip super_admin_only items if user is not super admin
filtered_items = [
item for item in section["items"]
if item["id"] in visible_items
and (not item.get("super_admin_only") or is_super_admin)
]
# Only include section if it has visible items
if filtered_items:
section["items"] = filtered_items
filtered_sections.append(section)
filtered_menu["sections"] = filtered_sections
return filtered_menu
# Convert to legacy format for backwards compatibility with existing templates
return menu_discovery_service.menu_to_legacy_format(sections)
# =========================================================================
# Menu Configuration (Super Admin)

View File

@@ -17,7 +17,7 @@ from typing import Any
from sqlalchemy.orm import Session
from app.core.config import settings
from models.database.admin import AdminSetting
from app.modules.tenancy.models import AdminSetting
logger = logging.getLogger(__name__)