diff --git a/alembic/versions/za0k1l2m3n4o5_add_admin_menu_config.py b/alembic/versions/za0k1l2m3n4o5_add_admin_menu_config.py new file mode 100644 index 00000000..b2c18cba --- /dev/null +++ b/alembic/versions/za0k1l2m3n4o5_add_admin_menu_config.py @@ -0,0 +1,128 @@ +"""Add admin menu configuration table + +Revision ID: za0k1l2m3n4o5 +Revises: z9j0k1l2m3n4 +Create Date: 2026-01-25 + +Adds configurable admin sidebar menus: +- Platform-level config: Controls which menu items platform admins see +- User-level config: Controls which menu items super admins see +- Opt-out model: All items visible by default +- Mandatory items enforced at application level (companies, vendors, users, settings) +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "za0k1l2m3n4o5" +down_revision = "z9j0k1l2m3n4" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Create admin_menu_configs table + op.create_table( + "admin_menu_configs", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column( + "platform_id", + sa.Integer(), + nullable=True, + comment="Platform scope - applies to all platform admins of this platform", + ), + sa.Column( + "user_id", + sa.Integer(), + nullable=True, + comment="User scope - applies to this specific super admin", + ), + sa.Column( + "menu_item_id", + sa.String(50), + nullable=False, + comment="Menu item identifier from registry (e.g., 'products', 'inventory')", + ), + sa.Column( + "is_visible", + sa.Boolean(), + nullable=False, + server_default="true", + comment="Whether this menu item is visible (False = hidden)", + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + onupdate=sa.func.now(), + ), + # Foreign keys + sa.ForeignKeyConstraint( + ["platform_id"], + ["platforms.id"], + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id"), + # Unique constraints + sa.UniqueConstraint("platform_id", "menu_item_id", name="uq_platform_menu_config"), + sa.UniqueConstraint("user_id", "menu_item_id", name="uq_user_menu_config"), + # Check constraint: exactly one scope must be set + sa.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", + ), + ) + + # Create indexes for performance + op.create_index( + "idx_admin_menu_configs_platform_id", + "admin_menu_configs", + ["platform_id"], + ) + op.create_index( + "idx_admin_menu_configs_user_id", + "admin_menu_configs", + ["user_id"], + ) + op.create_index( + "idx_admin_menu_configs_menu_item_id", + "admin_menu_configs", + ["menu_item_id"], + ) + op.create_index( + "idx_admin_menu_config_platform_visible", + "admin_menu_configs", + ["platform_id", "is_visible"], + ) + op.create_index( + "idx_admin_menu_config_user_visible", + "admin_menu_configs", + ["user_id", "is_visible"], + ) + + +def downgrade() -> None: + # Drop indexes + op.drop_index("idx_admin_menu_config_user_visible", table_name="admin_menu_configs") + op.drop_index("idx_admin_menu_config_platform_visible", table_name="admin_menu_configs") + op.drop_index("idx_admin_menu_configs_menu_item_id", table_name="admin_menu_configs") + op.drop_index("idx_admin_menu_configs_user_id", table_name="admin_menu_configs") + op.drop_index("idx_admin_menu_configs_platform_id", table_name="admin_menu_configs") + + # Drop table + op.drop_table("admin_menu_configs") diff --git a/alembic/versions/zb1l2m3n4o5p6_add_frontend_type_to_menu_config.py b/alembic/versions/zb1l2m3n4o5p6_add_frontend_type_to_menu_config.py new file mode 100644 index 00000000..ded81419 --- /dev/null +++ b/alembic/versions/zb1l2m3n4o5p6_add_frontend_type_to_menu_config.py @@ -0,0 +1,117 @@ +"""Add frontend_type to admin_menu_configs + +Revision ID: zb1l2m3n4o5p6 +Revises: za0k1l2m3n4o5 +Create Date: 2026-01-25 + +Adds frontend_type column to support both admin and vendor menu configuration: +- 'admin': Admin panel menus (super admins, platform admins) +- 'vendor': Vendor dashboard menus (configured per platform) + +Also updates unique constraints to include frontend_type and adds +a check constraint ensuring user_id scope is only used for admin frontend. +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "zb1l2m3n4o5p6" +down_revision = "za0k1l2m3n4o5" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 1. Create the enum type for frontend_type + frontend_type_enum = sa.Enum('admin', 'vendor', name='frontendtype') + frontend_type_enum.create(op.get_bind(), checkfirst=True) + + # 2. Add frontend_type column with default value + op.add_column( + "admin_menu_configs", + sa.Column( + "frontend_type", + sa.Enum('admin', 'vendor', name='frontendtype'), + nullable=False, + server_default="admin", + comment="Which frontend this config applies to (admin or vendor)", + ), + ) + + # 3. Create index on frontend_type + op.create_index( + "idx_admin_menu_configs_frontend_type", + "admin_menu_configs", + ["frontend_type"], + ) + + # 4. Drop old unique constraints + op.drop_constraint("uq_platform_menu_config", "admin_menu_configs", type_="unique") + op.drop_constraint("uq_user_menu_config", "admin_menu_configs", type_="unique") + + # 5. Create new unique constraints that include frontend_type + op.create_unique_constraint( + "uq_frontend_platform_menu_config", + "admin_menu_configs", + ["frontend_type", "platform_id", "menu_item_id"], + ) + op.create_unique_constraint( + "uq_frontend_user_menu_config", + "admin_menu_configs", + ["frontend_type", "user_id", "menu_item_id"], + ) + + # 6. Add check constraint: user_id scope only allowed for admin frontend + op.create_check_constraint( + "ck_user_scope_admin_only", + "admin_menu_configs", + "(user_id IS NULL) OR (frontend_type = 'admin')", + ) + + # 7. Create composite indexes for common queries + op.create_index( + "idx_admin_menu_config_frontend_platform", + "admin_menu_configs", + ["frontend_type", "platform_id"], + ) + op.create_index( + "idx_admin_menu_config_frontend_user", + "admin_menu_configs", + ["frontend_type", "user_id"], + ) + + +def downgrade() -> None: + # Drop new indexes + op.drop_index("idx_admin_menu_config_frontend_user", table_name="admin_menu_configs") + op.drop_index("idx_admin_menu_config_frontend_platform", table_name="admin_menu_configs") + + # Drop check constraint + op.drop_constraint("ck_user_scope_admin_only", "admin_menu_configs", type_="check") + + # Drop new unique constraints + op.drop_constraint("uq_frontend_user_menu_config", "admin_menu_configs", type_="unique") + op.drop_constraint("uq_frontend_platform_menu_config", "admin_menu_configs", type_="unique") + + # Restore old unique constraints + op.create_unique_constraint( + "uq_platform_menu_config", + "admin_menu_configs", + ["platform_id", "menu_item_id"], + ) + op.create_unique_constraint( + "uq_user_menu_config", + "admin_menu_configs", + ["user_id", "menu_item_id"], + ) + + # Drop frontend_type index + op.drop_index("idx_admin_menu_configs_frontend_type", table_name="admin_menu_configs") + + # Drop frontend_type column + op.drop_column("admin_menu_configs", "frontend_type") + + # Drop the enum type + sa.Enum('admin', 'vendor', name='frontendtype').drop(op.get_bind(), checkfirst=True) diff --git a/app/config/__init__.py b/app/config/__init__.py new file mode 100644 index 00000000..f0aa0e54 --- /dev/null +++ b/app/config/__init__.py @@ -0,0 +1,18 @@ +# app/config/__init__.py +"""Configuration modules for the application.""" + +from .menu_registry import ( + ADMIN_MENU_REGISTRY, + VENDOR_MENU_REGISTRY, + AdminMenuItem, + VendorMenuItem, + get_all_menu_item_ids, +) + +__all__ = [ + "ADMIN_MENU_REGISTRY", + "VENDOR_MENU_REGISTRY", + "AdminMenuItem", + "VendorMenuItem", + "get_all_menu_item_ids", +] diff --git a/app/config/menu_registry.py b/app/config/menu_registry.py new file mode 100644 index 00000000..386e6eab --- /dev/null +++ b/app/config/menu_registry.py @@ -0,0 +1,546 @@ +# app/config/menu_registry.py +""" +Menu registry for Admin and Vendor frontends. + +This module defines the complete menu structure for both frontends. +Menu items are identified by unique IDs that are used for: +- Storing visibility configuration in AdminMenuConfig +- Checking access in require_menu_access() dependency +- Rendering the sidebar dynamically + +The registry is the single source of truth for menu structure. +Database only stores visibility overrides (is_visible=False). +""" + +from enum import Enum + +from models.database.admin_menu_config import FrontendType + + +class AdminMenuItem(str, Enum): + """Admin frontend menu item identifiers.""" + + # Dashboard (always visible section) + DASHBOARD = "dashboard" + + # Super Admin section + ADMIN_USERS = "admin-users" + + # Platform Administration section + COMPANIES = "companies" + VENDORS = "vendors" + MESSAGES = "messages" + + # Vendor Operations section + VENDOR_PRODUCTS = "vendor-products" + CUSTOMERS = "customers" + INVENTORY = "inventory" + ORDERS = "orders" + + # Marketplace section + MARKETPLACE_LETZSHOP = "marketplace-letzshop" + + # Billing & Subscriptions section + SUBSCRIPTION_TIERS = "subscription-tiers" + SUBSCRIPTIONS = "subscriptions" + BILLING_HISTORY = "billing-history" + + # Content Management section + PLATFORMS = "platforms" + CONTENT_PAGES = "content-pages" + VENDOR_THEMES = "vendor-themes" + + # Developer Tools section + COMPONENTS = "components" + ICONS = "icons" + + # Platform Health section + PLATFORM_HEALTH = "platform-health" + TESTING = "testing" + CODE_QUALITY = "code-quality" + + # Platform Monitoring section + IMPORTS = "imports" + BACKGROUND_TASKS = "background-tasks" + LOGS = "logs" + NOTIFICATIONS = "notifications" + + # Platform Settings section + SETTINGS = "settings" + EMAIL_TEMPLATES = "email-templates" + MY_MENU = "my-menu" # Super admin only - personal menu configuration + + +class VendorMenuItem(str, Enum): + """Vendor frontend menu item identifiers.""" + + # Main section (always visible) + DASHBOARD = "dashboard" + ANALYTICS = "analytics" + + # Products & Inventory section + PRODUCTS = "products" + INVENTORY = "inventory" + MARKETPLACE = "marketplace" + + # Sales & Orders section + ORDERS = "orders" + LETZSHOP = "letzshop" + INVOICES = "invoices" + + # Customers section + CUSTOMERS = "customers" + MESSAGES = "messages" + NOTIFICATIONS = "notifications" + + # Shop & Content section + CONTENT_PAGES = "content-pages" + MEDIA = "media" + + # Account & Settings section + TEAM = "team" + PROFILE = "profile" + BILLING = "billing" + EMAIL_TEMPLATES = "email-templates" + SETTINGS = "settings" + + +# ============================================================================= +# Admin Menu Registry +# ============================================================================= + +ADMIN_MENU_REGISTRY = { + "frontend_type": FrontendType.ADMIN, + "sections": [ + { + "id": "main", + "label": None, # No header, always at top + "items": [ + { + "id": AdminMenuItem.DASHBOARD.value, + "label": "Dashboard", + "icon": "home", + "url": "/admin/dashboard", + }, + ], + }, + { + "id": "superAdmin", + "label": "Super Admin", + "super_admin_only": True, + "items": [ + { + "id": AdminMenuItem.ADMIN_USERS.value, + "label": "Admin Users", + "icon": "shield", + "url": "/admin/admin-users", + }, + ], + }, + { + "id": "platformAdmin", + "label": "Platform Administration", + "items": [ + { + "id": AdminMenuItem.COMPANIES.value, + "label": "Companies", + "icon": "office-building", + "url": "/admin/companies", + }, + { + "id": AdminMenuItem.VENDORS.value, + "label": "Vendors", + "icon": "shopping-bag", + "url": "/admin/vendors", + }, + { + "id": AdminMenuItem.MESSAGES.value, + "label": "Messages", + "icon": "chat-bubble-left-right", + "url": "/admin/messages", + }, + ], + }, + { + "id": "vendorOps", + "label": "Vendor Operations", + "items": [ + { + "id": AdminMenuItem.VENDOR_PRODUCTS.value, + "label": "Products", + "icon": "cube", + "url": "/admin/vendor-products", + }, + { + "id": AdminMenuItem.CUSTOMERS.value, + "label": "Customers", + "icon": "user-group", + "url": "/admin/customers", + }, + { + "id": AdminMenuItem.INVENTORY.value, + "label": "Inventory", + "icon": "archive", + "url": "/admin/inventory", + }, + { + "id": AdminMenuItem.ORDERS.value, + "label": "Orders", + "icon": "clipboard-list", + "url": "/admin/orders", + }, + ], + }, + { + "id": "marketplace", + "label": "Marketplace", + "items": [ + { + "id": AdminMenuItem.MARKETPLACE_LETZSHOP.value, + "label": "Letzshop", + "icon": "shopping-cart", + "url": "/admin/marketplace/letzshop", + }, + ], + }, + { + "id": "billing", + "label": "Billing & Subscriptions", + "items": [ + { + "id": AdminMenuItem.SUBSCRIPTION_TIERS.value, + "label": "Subscription Tiers", + "icon": "tag", + "url": "/admin/subscription-tiers", + }, + { + "id": AdminMenuItem.SUBSCRIPTIONS.value, + "label": "Vendor Subscriptions", + "icon": "credit-card", + "url": "/admin/subscriptions", + }, + { + "id": AdminMenuItem.BILLING_HISTORY.value, + "label": "Billing History", + "icon": "document-text", + "url": "/admin/billing-history", + }, + ], + }, + { + "id": "contentMgmt", + "label": "Content Management", + "items": [ + { + "id": AdminMenuItem.PLATFORMS.value, + "label": "Platforms", + "icon": "globe-alt", + "url": "/admin/platforms", + }, + { + "id": AdminMenuItem.CONTENT_PAGES.value, + "label": "Content Pages", + "icon": "document-text", + "url": "/admin/content-pages", + }, + { + "id": AdminMenuItem.VENDOR_THEMES.value, + "label": "Vendor Themes", + "icon": "color-swatch", + "url": "/admin/vendor-themes", + }, + ], + }, + { + "id": "devTools", + "label": "Developer Tools", + "items": [ + { + "id": AdminMenuItem.COMPONENTS.value, + "label": "Components", + "icon": "view-grid", + "url": "/admin/components", + }, + { + "id": AdminMenuItem.ICONS.value, + "label": "Icons", + "icon": "photograph", + "url": "/admin/icons", + }, + ], + }, + { + "id": "platformHealth", + "label": "Platform Health", + "items": [ + { + "id": AdminMenuItem.PLATFORM_HEALTH.value, + "label": "Capacity Monitor", + "icon": "chart-bar", + "url": "/admin/platform-health", + }, + { + "id": AdminMenuItem.TESTING.value, + "label": "Testing Hub", + "icon": "beaker", + "url": "/admin/testing", + }, + { + "id": AdminMenuItem.CODE_QUALITY.value, + "label": "Code Quality", + "icon": "shield-check", + "url": "/admin/code-quality", + }, + ], + }, + { + "id": "monitoring", + "label": "Platform Monitoring", + "items": [ + { + "id": AdminMenuItem.IMPORTS.value, + "label": "Import Jobs", + "icon": "cube", + "url": "/admin/imports", + }, + { + "id": AdminMenuItem.BACKGROUND_TASKS.value, + "label": "Background Tasks", + "icon": "collection", + "url": "/admin/background-tasks", + }, + { + "id": AdminMenuItem.LOGS.value, + "label": "Application Logs", + "icon": "document-text", + "url": "/admin/logs", + }, + { + "id": AdminMenuItem.NOTIFICATIONS.value, + "label": "Notifications", + "icon": "bell", + "url": "/admin/notifications", + }, + ], + }, + { + "id": "settingsSection", + "label": "Platform Settings", + "items": [ + { + "id": AdminMenuItem.SETTINGS.value, + "label": "General", + "icon": "cog", + "url": "/admin/settings", + }, + { + "id": AdminMenuItem.EMAIL_TEMPLATES.value, + "label": "Email Templates", + "icon": "mail", + "url": "/admin/email-templates", + }, + { + "id": AdminMenuItem.MY_MENU.value, + "label": "My Menu", + "icon": "view-grid", + "url": "/admin/my-menu", + "super_admin_only": True, # Only super admins can customize their menu + }, + ], + }, + ], +} + + +# ============================================================================= +# Vendor Menu Registry +# ============================================================================= + +VENDOR_MENU_REGISTRY = { + "frontend_type": FrontendType.VENDOR, + "sections": [ + { + "id": "main", + "label": None, # No header, always at top + "items": [ + { + "id": VendorMenuItem.DASHBOARD.value, + "label": "Dashboard", + "icon": "home", + "url": "/dashboard", # Relative to /vendor/{code}/ + }, + { + "id": VendorMenuItem.ANALYTICS.value, + "label": "Analytics", + "icon": "chart-bar", + "url": "/analytics", + }, + ], + }, + { + "id": "products", + "label": "Products & Inventory", + "items": [ + { + "id": VendorMenuItem.PRODUCTS.value, + "label": "All Products", + "icon": "shopping-bag", + "url": "/products", + }, + { + "id": VendorMenuItem.INVENTORY.value, + "label": "Inventory", + "icon": "clipboard-list", + "url": "/inventory", + }, + { + "id": VendorMenuItem.MARKETPLACE.value, + "label": "Marketplace Import", + "icon": "download", + "url": "/marketplace", + }, + ], + }, + { + "id": "sales", + "label": "Sales & Orders", + "items": [ + { + "id": VendorMenuItem.ORDERS.value, + "label": "Orders", + "icon": "document-text", + "url": "/orders", + }, + { + "id": VendorMenuItem.LETZSHOP.value, + "label": "Letzshop Orders", + "icon": "external-link", + "url": "/letzshop", + }, + { + "id": VendorMenuItem.INVOICES.value, + "label": "Invoices", + "icon": "currency-euro", + "url": "/invoices", + }, + ], + }, + { + "id": "customers", + "label": "Customers", + "items": [ + { + "id": VendorMenuItem.CUSTOMERS.value, + "label": "All Customers", + "icon": "user-group", + "url": "/customers", + }, + { + "id": VendorMenuItem.MESSAGES.value, + "label": "Messages", + "icon": "chat-bubble-left-right", + "url": "/messages", + }, + { + "id": VendorMenuItem.NOTIFICATIONS.value, + "label": "Notifications", + "icon": "bell", + "url": "/notifications", + }, + ], + }, + { + "id": "shop", + "label": "Shop & Content", + "items": [ + { + "id": VendorMenuItem.CONTENT_PAGES.value, + "label": "Content Pages", + "icon": "document-text", + "url": "/content-pages", + }, + { + "id": VendorMenuItem.MEDIA.value, + "label": "Media Library", + "icon": "photograph", + "url": "/media", + }, + ], + }, + { + "id": "account", + "label": "Account & Settings", + "items": [ + { + "id": VendorMenuItem.TEAM.value, + "label": "Team", + "icon": "user-group", + "url": "/team", + }, + { + "id": VendorMenuItem.PROFILE.value, + "label": "Profile", + "icon": "user", + "url": "/profile", + }, + { + "id": VendorMenuItem.BILLING.value, + "label": "Billing", + "icon": "credit-card", + "url": "/billing", + }, + { + "id": VendorMenuItem.EMAIL_TEMPLATES.value, + "label": "Email Templates", + "icon": "mail", + "url": "/email-templates", + }, + { + "id": VendorMenuItem.SETTINGS.value, + "label": "Settings", + "icon": "adjustments", + "url": "/settings", + }, + ], + }, + ], +} + + +# ============================================================================= +# Helper Functions +# ============================================================================= + + +def get_all_menu_item_ids(frontend_type: FrontendType) -> set[str]: + """Get all menu item IDs for a frontend type.""" + registry = ( + ADMIN_MENU_REGISTRY if frontend_type == FrontendType.ADMIN else VENDOR_MENU_REGISTRY + ) + items = set() + for section in registry["sections"]: + for item in section["items"]: + items.add(item["id"]) + return items + + +def get_menu_item(frontend_type: FrontendType, menu_item_id: str) -> dict | None: + """Get a menu item definition by ID.""" + registry = ( + ADMIN_MENU_REGISTRY if frontend_type == FrontendType.ADMIN else VENDOR_MENU_REGISTRY + ) + for section in registry["sections"]: + for item in section["items"]: + if item["id"] == menu_item_id: + return {**item, "section_id": section["id"], "section_label": section.get("label")} + return None + + +def is_super_admin_only_item(menu_item_id: str) -> bool: + """Check if a menu item is in a super_admin_only section.""" + for section in ADMIN_MENU_REGISTRY["sections"]: + if section.get("super_admin_only"): + for item in section["items"]: + if item["id"] == menu_item_id: + return True + return False diff --git a/app/services/platform_service.py b/app/services/platform_service.py index 248b12d9..ff8ccbb9 100644 --- a/app/services/platform_service.py +++ b/app/services/platform_service.py @@ -20,7 +20,7 @@ from sqlalchemy.orm import Session from app.exceptions.platform import ( PlatformNotFoundException, ) -from models.database.content_page import ContentPage +from app.modules.cms.models import ContentPage from models.database.platform import Platform from models.database.vendor_platform import VendorPlatform @@ -81,6 +81,28 @@ class PlatformService: """ return db.query(Platform).filter(Platform.code == code).first() + @staticmethod + def get_platform_by_id(db: Session, platform_id: int) -> Platform: + """ + Get platform by ID. + + Args: + db: Database session + platform_id: Platform ID + + Returns: + Platform object + + Raises: + PlatformNotFoundException: If platform not found + """ + platform = db.query(Platform).filter(Platform.id == platform_id).first() + + if not platform: + raise PlatformNotFoundException(str(platform_id)) + + return platform + @staticmethod def list_platforms( db: Session, include_inactive: bool = False diff --git a/app/templates/admin/my-menu-config.html b/app/templates/admin/my-menu-config.html new file mode 100644 index 00000000..a707936e --- /dev/null +++ b/app/templates/admin/my-menu-config.html @@ -0,0 +1,174 @@ +{# app/templates/admin/my-menu-config.html #} +{% extends "admin/base.html" %} +{% from 'shared/macros/alerts.html' import alert_dynamic, error_state %} +{% from 'shared/macros/headers.html' import page_header %} + +{% block title %}My Menu{% endblock %} + +{% block alpine_data %}adminMyMenuConfig(){% endblock %} + +{% block content %} +{{ page_header('My Menu Configuration', subtitle='Customize your personal admin sidebar', back_url='/admin/settings') }} + +{{ alert_dynamic(type='success', title='Success', message_var='successMessage', show_condition='successMessage') }} +{{ error_state('Error', show_condition='error') }} + + +
+ This configures your personal admin sidebar menu. These settings only affect your view. +
++ To configure menus for platform admins or vendors, go to Platforms and select a platform's Menu Configuration. +
+Total Items
+ +Visible
+ +Hidden
+ ++ Toggle visibility for menu items. Mandatory items cannot be hidden. +
+No menu items available.
++ Configure which menu items are visible for admins and vendors on this platform. +
+Total Items
+ +Visible
+ +Hidden
+ ++ Toggle visibility for menu items. Mandatory items cannot be hidden. +
+No menu items configured for this frontend type.
+