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:
@@ -6,8 +6,8 @@ Dashboard, settings, and profile management.
|
||||
Required for basic operation - cannot be disabled.
|
||||
"""
|
||||
|
||||
from app.modules.base import ModuleDefinition
|
||||
from models.database.admin_menu_config import FrontendType
|
||||
from app.modules.base import MenuItemDefinition, MenuSectionDefinition, ModuleDefinition
|
||||
from app.modules.enums import FrontendType
|
||||
|
||||
core_module = ModuleDefinition(
|
||||
code="core",
|
||||
@@ -21,6 +21,7 @@ core_module = ModuleDefinition(
|
||||
"settings",
|
||||
"profile",
|
||||
],
|
||||
# Legacy menu_items (IDs only)
|
||||
menu_items={
|
||||
FrontendType.ADMIN: [
|
||||
"dashboard",
|
||||
@@ -35,6 +36,95 @@ core_module = ModuleDefinition(
|
||||
"email-templates",
|
||||
],
|
||||
},
|
||||
# New module-driven menu definitions
|
||||
menus={
|
||||
FrontendType.ADMIN: [
|
||||
MenuSectionDefinition(
|
||||
id="main",
|
||||
label_key=None, # No header for main section
|
||||
icon=None,
|
||||
order=0,
|
||||
is_collapsible=False,
|
||||
items=[
|
||||
MenuItemDefinition(
|
||||
id="dashboard",
|
||||
label_key="core.menu.dashboard",
|
||||
icon="home",
|
||||
route="/admin/dashboard",
|
||||
order=10,
|
||||
is_mandatory=True,
|
||||
),
|
||||
],
|
||||
),
|
||||
MenuSectionDefinition(
|
||||
id="settings",
|
||||
label_key="core.menu.platform_settings",
|
||||
icon="cog",
|
||||
order=900,
|
||||
items=[
|
||||
MenuItemDefinition(
|
||||
id="settings",
|
||||
label_key="core.menu.general",
|
||||
icon="cog",
|
||||
route="/admin/settings",
|
||||
order=10,
|
||||
is_mandatory=True,
|
||||
),
|
||||
MenuItemDefinition(
|
||||
id="my-menu",
|
||||
label_key="core.menu.my_menu",
|
||||
icon="view-grid",
|
||||
route="/admin/my-menu",
|
||||
order=30,
|
||||
is_mandatory=True,
|
||||
is_super_admin_only=True,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
FrontendType.VENDOR: [
|
||||
MenuSectionDefinition(
|
||||
id="main",
|
||||
label_key=None,
|
||||
icon=None,
|
||||
order=0,
|
||||
is_collapsible=False,
|
||||
items=[
|
||||
MenuItemDefinition(
|
||||
id="dashboard",
|
||||
label_key="core.menu.dashboard",
|
||||
icon="home",
|
||||
route="/vendor/{vendor_code}/dashboard",
|
||||
order=10,
|
||||
is_mandatory=True,
|
||||
),
|
||||
],
|
||||
),
|
||||
MenuSectionDefinition(
|
||||
id="account",
|
||||
label_key="core.menu.account_settings",
|
||||
icon="user",
|
||||
order=900,
|
||||
items=[
|
||||
MenuItemDefinition(
|
||||
id="profile",
|
||||
label_key="core.menu.profile",
|
||||
icon="user",
|
||||
route="/vendor/{vendor_code}/profile",
|
||||
order=10,
|
||||
),
|
||||
MenuItemDefinition(
|
||||
id="settings",
|
||||
label_key="core.menu.settings",
|
||||
icon="cog",
|
||||
route="/vendor/{vendor_code}/settings",
|
||||
order=20,
|
||||
is_mandatory=True,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
__all__ = ["core_module"]
|
||||
|
||||
34
app/modules/core/exceptions.py
Normal file
34
app/modules/core/exceptions.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# app/modules/core/exceptions.py
|
||||
"""Core module exceptions.
|
||||
|
||||
Exceptions for core platform functionality including:
|
||||
- Menu configuration
|
||||
- Dashboard operations
|
||||
- Settings management
|
||||
"""
|
||||
|
||||
from app.exceptions import WizamartException
|
||||
|
||||
|
||||
class CoreException(WizamartException):
|
||||
"""Base exception for core module."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class MenuConfigurationError(CoreException):
|
||||
"""Error in menu configuration."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class SettingsError(CoreException):
|
||||
"""Error in platform settings."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class DashboardError(CoreException):
|
||||
"""Error in dashboard operations."""
|
||||
|
||||
pass
|
||||
71
app/modules/core/locales/de.json
Normal file
71
app/modules/core/locales/de.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"welcome": "Willkommen zurück",
|
||||
"overview": "Übersicht",
|
||||
"quick_stats": "Schnellstatistiken",
|
||||
"recent_activity": "Letzte Aktivitäten",
|
||||
"total_products": "Produkte gesamt",
|
||||
"total_orders": "Bestellungen gesamt",
|
||||
"total_customers": "Kunden gesamt",
|
||||
"total_revenue": "Gesamtumsatz",
|
||||
"active_products": "Aktive Produkte",
|
||||
"pending_orders": "Ausstehende Bestellungen",
|
||||
"new_customers": "Neue Kunden",
|
||||
"today": "Heute",
|
||||
"this_week": "Diese Woche",
|
||||
"this_month": "Dieser Monat",
|
||||
"this_year": "Dieses Jahr",
|
||||
"error_loading": "Fehler beim Laden des Dashboards",
|
||||
"no_data": "Keine Daten verfügbar"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"general": "Allgemein",
|
||||
"store": "Shop",
|
||||
"store_name": "Shop-Name",
|
||||
"store_description": "Shop-Beschreibung",
|
||||
"contact_email": "Kontakt-E-Mail",
|
||||
"contact_phone": "Kontakttelefon",
|
||||
"business_address": "Geschäftsadresse",
|
||||
"tax_number": "Steuernummer",
|
||||
"currency": "Währung",
|
||||
"timezone": "Zeitzone",
|
||||
"language": "Sprache",
|
||||
"language_settings": "Spracheinstellungen",
|
||||
"default_language": "Standardsprache",
|
||||
"dashboard_language": "Dashboard-Sprache",
|
||||
"storefront_language": "Shop-Sprache",
|
||||
"enabled_languages": "Aktivierte Sprachen",
|
||||
"notifications": "Benachrichtigungen",
|
||||
"email_notifications": "E-Mail-Benachrichtigungen",
|
||||
"integrations": "Integrationen",
|
||||
"api_keys": "API-Schlüssel",
|
||||
"webhooks": "Webhooks",
|
||||
"save_settings": "Einstellungen speichern",
|
||||
"settings_saved": "Einstellungen erfolgreich gespeichert"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Profil",
|
||||
"my_profile": "Mein Profil",
|
||||
"edit_profile": "Profil bearbeiten",
|
||||
"personal_info": "Persönliche Informationen",
|
||||
"first_name": "Vorname",
|
||||
"last_name": "Nachname",
|
||||
"email": "E-Mail",
|
||||
"phone": "Telefon",
|
||||
"avatar": "Profilbild",
|
||||
"change_avatar": "Profilbild ändern",
|
||||
"security": "Sicherheit",
|
||||
"two_factor": "Zwei-Faktor-Authentifizierung",
|
||||
"sessions": "Aktive Sitzungen",
|
||||
"preferences": "Präferenzen",
|
||||
"language_preference": "Sprachpräferenz",
|
||||
"save_profile": "Profil speichern",
|
||||
"profile_updated": "Profil erfolgreich aktualisiert"
|
||||
},
|
||||
"messages": {
|
||||
"failed_to_load_dashboard_data": "Failed to load dashboard data",
|
||||
"dashboard_refreshed": "Dashboard refreshed"
|
||||
}
|
||||
}
|
||||
71
app/modules/core/locales/fr.json
Normal file
71
app/modules/core/locales/fr.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
"welcome": "Bienvenue",
|
||||
"overview": "Vue d'ensemble",
|
||||
"quick_stats": "Statistiques rapides",
|
||||
"recent_activity": "Activité récente",
|
||||
"total_products": "Total des produits",
|
||||
"total_orders": "Total des commandes",
|
||||
"total_customers": "Total des clients",
|
||||
"total_revenue": "Chiffre d'affaires total",
|
||||
"active_products": "Produits actifs",
|
||||
"pending_orders": "Commandes en attente",
|
||||
"new_customers": "Nouveaux clients",
|
||||
"today": "Aujourd'hui",
|
||||
"this_week": "Cette semaine",
|
||||
"this_month": "Ce mois",
|
||||
"this_year": "Cette année",
|
||||
"error_loading": "Erreur lors du chargement du tableau de bord",
|
||||
"no_data": "Aucune donnée disponible"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Paramètres",
|
||||
"general": "Général",
|
||||
"store": "Boutique",
|
||||
"store_name": "Nom de la boutique",
|
||||
"store_description": "Description de la boutique",
|
||||
"contact_email": "E-mail de contact",
|
||||
"contact_phone": "Téléphone de contact",
|
||||
"business_address": "Adresse professionnelle",
|
||||
"tax_number": "Numéro de TVA",
|
||||
"currency": "Devise",
|
||||
"timezone": "Fuseau horaire",
|
||||
"language": "Langue",
|
||||
"language_settings": "Paramètres de langue",
|
||||
"default_language": "Langue par défaut",
|
||||
"dashboard_language": "Langue du tableau de bord",
|
||||
"storefront_language": "Langue de la boutique",
|
||||
"enabled_languages": "Langues activées",
|
||||
"notifications": "Notifications",
|
||||
"email_notifications": "Notifications par e-mail",
|
||||
"integrations": "Intégrations",
|
||||
"api_keys": "Clés API",
|
||||
"webhooks": "Webhooks",
|
||||
"save_settings": "Enregistrer les paramètres",
|
||||
"settings_saved": "Paramètres enregistrés avec succès"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Profil",
|
||||
"my_profile": "Mon profil",
|
||||
"edit_profile": "Modifier le profil",
|
||||
"personal_info": "Informations personnelles",
|
||||
"first_name": "Prénom",
|
||||
"last_name": "Nom",
|
||||
"email": "E-mail",
|
||||
"phone": "Téléphone",
|
||||
"avatar": "Avatar",
|
||||
"change_avatar": "Changer l'avatar",
|
||||
"security": "Sécurité",
|
||||
"two_factor": "Authentification à deux facteurs",
|
||||
"sessions": "Sessions actives",
|
||||
"preferences": "Préférences",
|
||||
"language_preference": "Préférence de langue",
|
||||
"save_profile": "Enregistrer le profil",
|
||||
"profile_updated": "Profil mis à jour avec succès"
|
||||
},
|
||||
"messages": {
|
||||
"failed_to_load_dashboard_data": "Failed to load dashboard data",
|
||||
"dashboard_refreshed": "Dashboard refreshed"
|
||||
}
|
||||
}
|
||||
71
app/modules/core/locales/lb.json
Normal file
71
app/modules/core/locales/lb.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"welcome": "Wëllkomm zréck",
|
||||
"overview": "Iwwersiicht",
|
||||
"quick_stats": "Séier Statistiken",
|
||||
"recent_activity": "Rezent Aktivitéit",
|
||||
"total_products": "Produkter insgesamt",
|
||||
"total_orders": "Bestellungen insgesamt",
|
||||
"total_customers": "Clienten insgesamt",
|
||||
"total_revenue": "Ëmsaz insgesamt",
|
||||
"active_products": "Aktiv Produkter",
|
||||
"pending_orders": "Aussteesend Bestellungen",
|
||||
"new_customers": "Nei Clienten",
|
||||
"today": "Haut",
|
||||
"this_week": "Dës Woch",
|
||||
"this_month": "Dëse Mount",
|
||||
"this_year": "Dëst Joer",
|
||||
"error_loading": "Feeler beim Lueden vum Dashboard",
|
||||
"no_data": "Keng Donnéeën disponibel"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Astellungen",
|
||||
"general": "Allgemeng",
|
||||
"store": "Buttek",
|
||||
"store_name": "Butteknumm",
|
||||
"store_description": "Buttekbeschreiwung",
|
||||
"contact_email": "Kontakt E-Mail",
|
||||
"contact_phone": "Kontakt Telefon",
|
||||
"business_address": "Geschäftsadress",
|
||||
"tax_number": "Steiernummer",
|
||||
"currency": "Wärung",
|
||||
"timezone": "Zäitzon",
|
||||
"language": "Sprooch",
|
||||
"language_settings": "Sproochastellungen",
|
||||
"default_language": "Standard Sprooch",
|
||||
"dashboard_language": "Dashboard Sprooch",
|
||||
"storefront_language": "Buttek Sprooch",
|
||||
"enabled_languages": "Aktivéiert Sproochen",
|
||||
"notifications": "Notifikatiounen",
|
||||
"email_notifications": "E-Mail Notifikatiounen",
|
||||
"integrations": "Integratiounen",
|
||||
"api_keys": "API Schlësselen",
|
||||
"webhooks": "Webhooks",
|
||||
"save_settings": "Astellunge späicheren",
|
||||
"settings_saved": "Astellungen erfollegräich gespäichert"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Profil",
|
||||
"my_profile": "Mäi Profil",
|
||||
"edit_profile": "Profil änneren",
|
||||
"personal_info": "Perséinlech Informatiounen",
|
||||
"first_name": "Virnumm",
|
||||
"last_name": "Nonumm",
|
||||
"email": "E-Mail",
|
||||
"phone": "Telefon",
|
||||
"avatar": "Avatar",
|
||||
"change_avatar": "Avatar änneren",
|
||||
"security": "Sécherheet",
|
||||
"two_factor": "Zwee-Faktor Authentifikatioun",
|
||||
"sessions": "Aktiv Sessiounen",
|
||||
"preferences": "Astellungen",
|
||||
"language_preference": "Sproochpräferenz",
|
||||
"save_profile": "Profil späicheren",
|
||||
"profile_updated": "Profil erfollegräich aktualiséiert"
|
||||
},
|
||||
"messages": {
|
||||
"failed_to_load_dashboard_data": "Failed to load dashboard data",
|
||||
"dashboard_refreshed": "Dashboard refreshed"
|
||||
}
|
||||
}
|
||||
18
app/modules/core/models/__init__.py
Normal file
18
app/modules/core/models/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# app/modules/core/models/__init__.py
|
||||
"""
|
||||
Core module database models.
|
||||
|
||||
This is the canonical location for core module models.
|
||||
"""
|
||||
|
||||
from app.modules.core.models.admin_menu_config import (
|
||||
AdminMenuConfig,
|
||||
FrontendType,
|
||||
MANDATORY_MENU_ITEMS,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AdminMenuConfig",
|
||||
"FrontendType",
|
||||
"MANDATORY_MENU_ITEMS",
|
||||
]
|
||||
223
app/modules/core/models/admin_menu_config.py
Normal file
223
app/modules/core/models/admin_menu_config.py
Normal file
@@ -0,0 +1,223 @@
|
||||
# app/modules/core/models/admin_menu_config.py
|
||||
"""
|
||||
Menu visibility configuration for admin and vendor frontends.
|
||||
|
||||
Supports two frontend types:
|
||||
- 'admin': Admin panel menus (for super admins and platform admins)
|
||||
- 'vendor': Vendor dashboard menus (configured per platform)
|
||||
|
||||
Supports two scopes:
|
||||
- Platform-level: Menu config for a platform (platform_id is set)
|
||||
→ For admin frontend: applies to platform admins
|
||||
→ For vendor frontend: applies to all vendors on that platform
|
||||
- User-level: Menu config for a specific super admin (user_id is set)
|
||||
→ Only for admin frontend (super admins configuring their own menu)
|
||||
|
||||
Design:
|
||||
- Opt-out model: All items visible by default, store hidden items
|
||||
- Mandatory items: Some items cannot be hidden (defined per frontend type)
|
||||
- Only stores non-default state (is_visible=False) to keep table small
|
||||
"""
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
Column,
|
||||
Enum,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
# Import FrontendType and MANDATORY_MENU_ITEMS from the central location
|
||||
from app.modules.enums import FrontendType, MANDATORY_MENU_ITEMS
|
||||
|
||||
|
||||
class AdminMenuConfig(Base, TimestampMixin):
|
||||
"""
|
||||
Menu visibility configuration for admin and vendor frontends.
|
||||
|
||||
Supports two frontend types:
|
||||
- 'admin': Admin panel menus
|
||||
- 'vendor': Vendor dashboard menus
|
||||
|
||||
Supports two scopes:
|
||||
- Platform scope: platform_id is set
|
||||
→ Admin: applies to platform admins of that platform
|
||||
→ Vendor: applies to all vendors on that platform
|
||||
- User scope: user_id is set (admin frontend only)
|
||||
→ Applies to a specific super admin user
|
||||
|
||||
Resolution order for admin frontend:
|
||||
- Platform admins: Check platform config → fall back to default
|
||||
- Super admins: Check user config → fall back to default
|
||||
|
||||
Resolution order for vendor frontend:
|
||||
- Check platform config → fall back to default
|
||||
|
||||
Examples:
|
||||
- Platform "OMS" wants to hide "inventory" from admin panel
|
||||
→ frontend_type='admin', platform_id=1, menu_item_id="inventory", is_visible=False
|
||||
|
||||
- Platform "OMS" wants to hide "letzshop" from vendor dashboard
|
||||
→ frontend_type='vendor', platform_id=1, menu_item_id="letzshop", is_visible=False
|
||||
|
||||
- Super admin "john" wants to hide "code-quality" from their admin panel
|
||||
→ frontend_type='admin', user_id=5, menu_item_id="code-quality", is_visible=False
|
||||
"""
|
||||
|
||||
__tablename__ = "admin_menu_configs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# ========================================================================
|
||||
# Frontend Type
|
||||
# ========================================================================
|
||||
|
||||
frontend_type = Column(
|
||||
Enum(FrontendType, values_callable=lambda obj: [e.value for e in obj]),
|
||||
nullable=False,
|
||||
default=FrontendType.ADMIN,
|
||||
index=True,
|
||||
comment="Which frontend this config applies to (admin or vendor)",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Scope: Platform scope OR User scope (for admin frontend only)
|
||||
# ========================================================================
|
||||
|
||||
platform_id = Column(
|
||||
Integer,
|
||||
ForeignKey("platforms.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="Platform scope - applies to users/vendors of this platform",
|
||||
)
|
||||
|
||||
user_id = Column(
|
||||
Integer,
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="User scope - applies to this specific super admin (admin frontend only)",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Menu Item Configuration
|
||||
# ========================================================================
|
||||
|
||||
menu_item_id = Column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Menu item identifier from registry (e.g., 'products', 'inventory')",
|
||||
)
|
||||
|
||||
is_visible = Column(
|
||||
Boolean,
|
||||
default=True,
|
||||
nullable=False,
|
||||
comment="Whether this menu item is visible (False = hidden)",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Relationships
|
||||
# ========================================================================
|
||||
|
||||
platform = relationship(
|
||||
"Platform",
|
||||
back_populates="menu_configs",
|
||||
)
|
||||
|
||||
user = relationship(
|
||||
"User",
|
||||
back_populates="menu_configs",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Constraints
|
||||
# ========================================================================
|
||||
|
||||
__table_args__ = (
|
||||
# Unique constraint: one config per frontend+platform+menu_item
|
||||
UniqueConstraint(
|
||||
"frontend_type",
|
||||
"platform_id",
|
||||
"menu_item_id",
|
||||
name="uq_frontend_platform_menu_config",
|
||||
),
|
||||
# Unique constraint: one config per frontend+user+menu_item
|
||||
UniqueConstraint(
|
||||
"frontend_type",
|
||||
"user_id",
|
||||
"menu_item_id",
|
||||
name="uq_frontend_user_menu_config",
|
||||
),
|
||||
# Check: exactly one scope must be set (platform_id XOR user_id)
|
||||
CheckConstraint(
|
||||
"(platform_id IS NOT NULL AND user_id IS NULL) OR "
|
||||
"(platform_id IS NULL AND user_id IS NOT NULL)",
|
||||
name="ck_admin_menu_config_scope",
|
||||
),
|
||||
# Check: user_id scope only allowed for admin frontend
|
||||
CheckConstraint(
|
||||
"(user_id IS NULL) OR (frontend_type = 'admin')",
|
||||
name="ck_user_scope_admin_only",
|
||||
),
|
||||
# Performance indexes
|
||||
Index(
|
||||
"idx_admin_menu_config_frontend_platform",
|
||||
"frontend_type",
|
||||
"platform_id",
|
||||
),
|
||||
Index(
|
||||
"idx_admin_menu_config_frontend_user",
|
||||
"frontend_type",
|
||||
"user_id",
|
||||
),
|
||||
Index(
|
||||
"idx_admin_menu_config_platform_visible",
|
||||
"platform_id",
|
||||
"is_visible",
|
||||
),
|
||||
Index(
|
||||
"idx_admin_menu_config_user_visible",
|
||||
"user_id",
|
||||
"is_visible",
|
||||
),
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Properties
|
||||
# ========================================================================
|
||||
|
||||
@property
|
||||
def scope_type(self) -> str:
|
||||
"""Get the scope type for this config."""
|
||||
if self.platform_id:
|
||||
return "platform"
|
||||
return "user"
|
||||
|
||||
@property
|
||||
def scope_id(self) -> int:
|
||||
"""Get the scope ID (platform_id or user_id)."""
|
||||
return self.platform_id or self.user_id
|
||||
|
||||
def __repr__(self) -> str:
|
||||
scope = f"platform_id={self.platform_id}" if self.platform_id else f"user_id={self.user_id}"
|
||||
return (
|
||||
f"<AdminMenuConfig("
|
||||
f"frontend_type='{self.frontend_type.value}', "
|
||||
f"{scope}, "
|
||||
f"menu_item_id='{self.menu_item_id}', "
|
||||
f"is_visible={self.is_visible})>"
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["AdminMenuConfig", "FrontendType", "MANDATORY_MENU_ITEMS"]
|
||||
@@ -5,15 +5,18 @@ Core module admin API routes.
|
||||
Aggregates all admin core routes:
|
||||
- /dashboard/* - Admin dashboard and statistics
|
||||
- /settings/* - Platform settings management
|
||||
- /menu-config/* - Menu visibility configuration
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .admin_dashboard import admin_dashboard_router
|
||||
from .admin_settings import admin_settings_router
|
||||
from .admin_menu_config import router as admin_menu_config_router
|
||||
|
||||
admin_router = APIRouter()
|
||||
|
||||
# Aggregate all core admin routes
|
||||
admin_router.include_router(admin_dashboard_router, tags=["admin-dashboard"])
|
||||
admin_router.include_router(admin_settings_router, tags=["admin-settings"])
|
||||
admin_router.include_router(admin_menu_config_router, tags=["admin-menu-config"])
|
||||
|
||||
463
app/modules/core/routes/api/admin_menu_config.py
Normal file
463
app/modules/core/routes/api/admin_menu_config.py
Normal file
@@ -0,0 +1,463 @@
|
||||
# app/modules/core/routes/api/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.modules.core.services.menu_service import MenuItemConfig, menu_service
|
||||
from app.modules.tenancy.services.platform_service import platform_service
|
||||
from app.modules.enums import FrontendType # noqa: API-007 - Enum for type safety
|
||||
from models.schema.auth import UserContext
|
||||
|
||||
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: UserContext = 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: UserContext = 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: UserContext = 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: UserContext = 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: UserContext = 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: UserContext = 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: UserContext = 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: UserContext = 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: UserContext = 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: UserContext = 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,
|
||||
)
|
||||
@@ -23,7 +23,7 @@ from app.modules.tenancy.exceptions import ConfirmationRequiredException
|
||||
from app.modules.monitoring.services.admin_audit_service import admin_audit_service
|
||||
from app.modules.core.services.admin_settings_service import admin_settings_service
|
||||
from models.schema.auth import UserContext
|
||||
from models.schema.admin import (
|
||||
from app.modules.tenancy.schemas.admin import (
|
||||
AdminSettingCreate,
|
||||
AdminSettingDefaultResponse,
|
||||
AdminSettingListResponse,
|
||||
@@ -528,7 +528,7 @@ def update_email_settings(
|
||||
Settings are stored in the database and override .env values.
|
||||
Only non-null values are updated.
|
||||
"""
|
||||
from models.schema.admin import AdminSettingCreate
|
||||
from app.modules.tenancy.schemas.admin import AdminSettingCreate
|
||||
|
||||
updated_keys = []
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ from sqlalchemy.orm import Session
|
||||
from app.api.deps import get_current_admin_optional, get_db, require_menu_access
|
||||
from app.modules.core.utils.page_context import get_admin_context
|
||||
from app.templates_config import templates
|
||||
from models.database.admin_menu_config import FrontendType
|
||||
from models.database.user import User
|
||||
from app.modules.enums import FrontendType
|
||||
from app.modules.tenancy.models import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from sqlalchemy.orm import Session
|
||||
from app.api.deps import get_current_vendor_from_cookie_or_header, get_db
|
||||
from app.modules.core.utils.page_context import get_vendor_context
|
||||
from app.templates_config import templates
|
||||
from models.database.user import User
|
||||
from app.modules.tenancy.models import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
0
app/modules/core/schemas/__init__.py
Normal file
0
app/modules/core/schemas/__init__.py
Normal 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",
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ function adminDashboard() {
|
||||
* Initialize dashboard
|
||||
*/
|
||||
async init() {
|
||||
// Load i18n translations
|
||||
await I18n.loadModule('core');
|
||||
|
||||
// Guard against multiple initialization
|
||||
if (window._dashboardInitialized) {
|
||||
dashLog.warn('Dashboard already initialized, skipping...');
|
||||
@@ -79,7 +82,7 @@ function adminDashboard() {
|
||||
} catch (error) {
|
||||
window.LogConfig.logError(error, 'Dashboard Load');
|
||||
this.error = error.message;
|
||||
Utils.showToast('Failed to load dashboard data', 'error');
|
||||
Utils.showToast(I18n.t('core.messages.failed_to_load_dashboard_data'), 'error');
|
||||
|
||||
} finally {
|
||||
this.loading = false;
|
||||
@@ -182,7 +185,7 @@ function adminDashboard() {
|
||||
async refresh() {
|
||||
dashLog.info('=== DASHBOARD REFRESH TRIGGERED ===');
|
||||
await this.loadDashboard();
|
||||
Utils.showToast('Dashboard refreshed', 'success');
|
||||
Utils.showToast(I18n.t('core.messages.dashboard_refreshed'), 'success');
|
||||
dashLog.info('=== DASHBOARD REFRESH COMPLETE ===');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -14,8 +14,8 @@ from sqlalchemy.orm import Session
|
||||
from app.core.config import settings
|
||||
from app.modules.core.services.platform_settings_service import platform_settings_service
|
||||
from app.utils.i18n import get_jinja2_globals
|
||||
from models.database.user import User
|
||||
from models.database.vendor import Vendor
|
||||
from app.modules.tenancy.models import User
|
||||
from app.modules.tenancy.models import Vendor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user