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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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__)
|
||||
|
||||
446
app/modules/core/services/menu_discovery_service.py
Normal file
446
app/modules/core/services/menu_discovery_service.py
Normal 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",
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user