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

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

View 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

View 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"
}
}

View 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"
}
}

View 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"
}
}

View 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",
]

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 ===');
}
};

View File

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