feat: add admin menu configuration and sidebar improvements

- Add AdminMenuConfig model for per-platform menu customization
- Add menu registry for centralized menu configuration
- Add my-menu-config and platform-menu-config admin pages
- Update sidebar with improved layout and Alpine.js interactions
- Add FrontendType enum for admin/vendor menu separation
- Document self-contained module patterns in session note

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-27 20:34:58 +01:00
parent 32e43efb3c
commit 9a828999fe
14 changed files with 2346 additions and 619 deletions

View File

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

View File

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

18
app/config/__init__.py Normal file
View File

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

546
app/config/menu_registry.py Normal file
View File

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

View File

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

View File

@@ -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') }}
<!-- Info Box -->
<div class="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div class="flex items-start">
<span x-html="$icon('information-circle', 'w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 mr-3')"></span>
<div>
<p class="text-sm text-blue-800 dark:text-blue-200">
This configures <strong>your personal</strong> admin sidebar menu. These settings only affect your view.
</p>
<p class="text-sm text-blue-700 dark:text-blue-300 mt-1">
To configure menus for platform admins or vendors, go to <a href="/admin/platforms" class="underline hover:no-underline">Platforms</a> and select a platform's Menu Configuration.
</p>
</div>
</div>
</div>
<!-- Stats Cards -->
<div class="grid gap-4 mb-6 md:grid-cols-3">
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
<span x-html="$icon('view-grid', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Total Items</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="menuConfig?.total_items || 0"></p>
</div>
</div>
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Visible</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="menuConfig?.visible_items || 0"></p>
</div>
</div>
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-gray-500 bg-gray-100 rounded-full dark:text-gray-100 dark:bg-gray-600">
<span x-html="$icon('eye-off', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Hidden</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="menuConfig?.hidden_items || 0"></p>
</div>
</div>
</div>
<!-- Actions -->
<div class="mb-4 flex items-center justify-between">
<p class="text-sm text-gray-500 dark:text-gray-400">
Toggle visibility for menu items. Mandatory items cannot be hidden.
</p>
<div class="flex gap-2">
<button
@click="showAll()"
:disabled="saving"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600"
>
<span x-html="$icon('eye', 'w-4 h-4 mr-2')"></span>
Show All
</button>
<button
@click="resetToDefaults()"
:disabled="saving"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600"
>
<span x-html="$icon('eye-off', 'w-4 h-4 mr-2')"></span>
Hide All
</button>
</div>
</div>
<!-- Loading State -->
<div x-show="loading" class="flex items-center justify-center py-12 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<span x-html="$icon('refresh', 'w-8 h-8 animate-spin text-purple-600')"></span>
<span class="ml-3 text-gray-500 dark:text-gray-400">Loading menu configuration...</span>
</div>
<!-- Menu Items by Section -->
<div x-show="!loading" class="space-y-6">
<template x-for="section in groupedItems" :key="section.id">
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 overflow-hidden">
<!-- Section Header -->
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div class="flex items-center">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="section.label || 'Main'"></h3>
<span
x-show="section.isSuperAdminOnly"
class="ml-2 px-2 py-0.5 text-xs font-medium rounded bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200"
>
Super Admin Only
</span>
</div>
<span class="text-xs text-gray-500 dark:text-gray-400" x-text="`${section.visibleCount}/${section.items.length} visible`"></span>
</div>
</div>
<!-- Section Items -->
<div class="divide-y divide-gray-100 dark:divide-gray-700">
<template x-for="item in section.items" :key="item.id">
<div class="flex items-center justify-between px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/30">
<div class="flex items-center">
<span x-html="$icon(item.icon, 'w-5 h-5 text-gray-400 mr-3')"></span>
<div>
<p class="text-sm font-medium text-gray-700 dark:text-gray-200" x-text="item.label"></p>
<p class="text-xs text-gray-400 dark:text-gray-500" x-text="item.url"></p>
</div>
</div>
<div class="flex items-center gap-3">
<!-- Mandatory Badge -->
<span
x-show="item.is_mandatory"
class="px-2 py-0.5 text-xs font-medium rounded bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
>
Mandatory
</span>
<!-- Toggle Switch -->
<button
@click="toggleVisibility(item)"
:disabled="item.is_mandatory || saving"
:class="{
'bg-purple-600': item.is_visible,
'bg-gray-200 dark:bg-gray-600': !item.is_visible,
'opacity-50 cursor-not-allowed': item.is_mandatory
}"
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2"
role="switch"
:aria-checked="item.is_visible"
>
<span
:class="{
'translate-x-5': item.is_visible,
'translate-x-0': !item.is_visible
}"
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
></span>
</button>
</div>
</div>
</template>
</div>
</div>
</template>
<!-- Empty State -->
<div x-show="groupedItems.length === 0" class="bg-white rounded-lg shadow-xs dark:bg-gray-800 p-8 text-center">
<span x-html="$icon('view-grid', 'w-12 h-12 mx-auto text-gray-400')"></span>
<p class="mt-4 text-gray-500 dark:text-gray-400">No menu items available.</p>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', path='admin/js/my-menu-config.js') }}"></script>
{% endblock %}

View File

@@ -38,9 +38,9 @@
</ul>
{% endmacro %}
{# Macro for menu item #}
{# Macro for menu item with visibility check #}
{% macro menu_item(page_id, url, icon, label) %}
<li class="relative px-6 py-3">
<li x-show="isMenuItemVisible('{{ page_id }}')" class="relative px-6 py-3">
<span x-show="currentPage === '{{ page_id }}'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === '{{ page_id }}' ? 'text-gray-800 dark:text-gray-100' : ''"
@@ -56,7 +56,7 @@
============================================================================ #}
{% macro sidebar_content() %}
<div class="py-4 text-gray-500 dark:text-gray-400">
<div class="py-4 text-gray-500 dark:text-gray-400" x-init="loadMenuConfig()">
<a class="ml-6 text-lg font-bold text-gray-800 dark:text-gray-200" href="/admin/dashboard">
Admin Portal
</a>
@@ -77,80 +77,110 @@
</template>
<!-- Platform Administration Section -->
{{ section_header('Platform Administration', 'platformAdmin') }}
{% call section_content('platformAdmin') %}
{{ menu_item('companies', '/admin/companies', 'office-building', 'Companies') }}
{{ menu_item('vendors', '/admin/vendors', 'shopping-bag', 'Vendors') }}
{{ menu_item('messages', '/admin/messages', 'chat-bubble-left-right', 'Messages') }}
{% endcall %}
<div x-show="isSectionVisible('platformAdmin')">
{{ section_header('Platform Administration', 'platformAdmin') }}
{% call section_content('platformAdmin') %}
{{ menu_item('companies', '/admin/companies', 'office-building', 'Companies') }}
{{ menu_item('vendors', '/admin/vendors', 'shopping-bag', 'Vendors') }}
{{ menu_item('messages', '/admin/messages', 'chat-bubble-left-right', 'Messages') }}
{% endcall %}
</div>
<!-- Vendor Operations Section -->
{{ section_header('Vendor Operations', 'vendorOps') }}
{% call section_content('vendorOps') %}
{{ menu_item('vendor-products', '/admin/vendor-products', 'cube', 'Products') }}
{{ menu_item('customers', '/admin/customers', 'user-group', 'Customers') }}
{{ menu_item('inventory', '/admin/inventory', 'archive', 'Inventory') }}
{{ menu_item('orders', '/admin/orders', 'clipboard-list', 'Orders') }}
{# Future items - uncomment when implemented:
{{ menu_item('shipping', '/admin/shipping', 'truck', 'Shipping') }}
#}
{% endcall %}
<div x-show="isSectionVisible('vendorOps')">
{{ section_header('Vendor Operations', 'vendorOps') }}
{% call section_content('vendorOps') %}
{{ menu_item('vendor-products', '/admin/vendor-products', 'cube', 'Products') }}
{{ menu_item('customers', '/admin/customers', 'user-group', 'Customers') }}
{{ menu_item('inventory', '/admin/inventory', 'archive', 'Inventory') }}
{{ menu_item('orders', '/admin/orders', 'clipboard-list', 'Orders') }}
{# Future items - uncomment when implemented:
{{ menu_item('shipping', '/admin/shipping', 'truck', 'Shipping') }}
#}
{% endcall %}
</div>
<!-- Marketplace Section -->
{{ section_header('Marketplace', 'marketplace') }}
{% call section_content('marketplace') %}
{{ menu_item('marketplace-letzshop', '/admin/marketplace/letzshop', 'shopping-cart', 'Letzshop') }}
{% endcall %}
<div x-show="isSectionVisible('marketplace')">
{{ section_header('Marketplace', 'marketplace') }}
{% call section_content('marketplace') %}
{{ menu_item('marketplace-letzshop', '/admin/marketplace/letzshop', 'shopping-cart', 'Letzshop') }}
{% endcall %}
</div>
<!-- Billing & Subscriptions Section -->
{{ section_header('Billing & Subscriptions', 'billing') }}
{% call section_content('billing') %}
{{ menu_item('subscription-tiers', '/admin/subscription-tiers', 'tag', 'Subscription Tiers') }}
{{ menu_item('subscriptions', '/admin/subscriptions', 'credit-card', 'Vendor Subscriptions') }}
{{ menu_item('billing-history', '/admin/billing-history', 'document-text', 'Billing History') }}
{% endcall %}
<div x-show="isSectionVisible('billing')">
{{ section_header('Billing & Subscriptions', 'billing') }}
{% call section_content('billing') %}
{{ menu_item('subscription-tiers', '/admin/subscription-tiers', 'tag', 'Subscription Tiers') }}
{{ menu_item('subscriptions', '/admin/subscriptions', 'credit-card', 'Vendor Subscriptions') }}
{{ menu_item('billing-history', '/admin/billing-history', 'document-text', 'Billing History') }}
{% endcall %}
</div>
<!-- Content Management Section -->
{{ section_header('Content Management', 'contentMgmt') }}
{% call section_content('contentMgmt') %}
{{ menu_item('platforms', '/admin/platforms', 'globe-alt', 'Platforms') }}
{{ menu_item('content-pages', '/admin/content-pages', 'document-text', 'Content Pages') }}
{{ menu_item('vendor-theme', '/admin/vendor-themes', 'color-swatch', 'Vendor Themes') }}
{% endcall %}
<div x-show="isSectionVisible('contentMgmt')">
{{ section_header('Content Management', 'contentMgmt') }}
{% call section_content('contentMgmt') %}
{{ menu_item('platforms', '/admin/platforms', 'globe-alt', 'Platforms') }}
{{ menu_item('content-pages', '/admin/content-pages', 'document-text', 'Content Pages') }}
{{ menu_item('vendor-themes', '/admin/vendor-themes', 'color-swatch', 'Vendor Themes') }}
{% endcall %}
</div>
<!-- Developer Tools Section -->
{{ section_header('Developer Tools', 'devTools') }}
{% call section_content('devTools') %}
{{ menu_item('components', '/admin/components', 'view-grid', 'Components') }}
{{ menu_item('icons', '/admin/icons', 'photograph', 'Icons') }}
{% endcall %}
<div x-show="isSectionVisible('devTools')">
{{ section_header('Developer Tools', 'devTools') }}
{% call section_content('devTools') %}
{{ menu_item('components', '/admin/components', 'view-grid', 'Components') }}
{{ menu_item('icons', '/admin/icons', 'photograph', 'Icons') }}
{% endcall %}
</div>
<!-- Platform Health Section -->
{{ section_header('Platform Health', 'platformHealth') }}
{% call section_content('platformHealth') %}
{{ menu_item('platform-health', '/admin/platform-health', 'chart-bar', 'Capacity Monitor') }}
{{ menu_item('testing', '/admin/testing', 'beaker', 'Testing Hub') }}
{{ menu_item('code-quality', '/admin/code-quality', 'shield-check', 'Code Quality') }}
{% endcall %}
<div x-show="isSectionVisible('platformHealth')">
{{ section_header('Platform Health', 'platformHealth') }}
{% call section_content('platformHealth') %}
{{ menu_item('platform-health', '/admin/platform-health', 'chart-bar', 'Capacity Monitor') }}
{{ menu_item('testing', '/admin/testing', 'beaker', 'Testing Hub') }}
{{ menu_item('code-quality', '/admin/code-quality', 'shield-check', 'Code Quality') }}
{% endcall %}
</div>
<!-- Platform Monitoring Section -->
{{ section_header('Platform Monitoring', 'monitoring') }}
{% call section_content('monitoring') %}
{{ menu_item('imports', '/admin/imports', 'cube', 'Import Jobs') }}
{{ menu_item('background-tasks', '/admin/background-tasks', 'collection', 'Background Tasks') }}
{{ menu_item('logs', '/admin/logs', 'document-text', 'Application Logs') }}
{{ menu_item('notifications', '/admin/notifications', 'bell', 'Notifications') }}
{% endcall %}
<div x-show="isSectionVisible('monitoring')">
{{ section_header('Platform Monitoring', 'monitoring') }}
{% call section_content('monitoring') %}
{{ menu_item('imports', '/admin/imports', 'cube', 'Import Jobs') }}
{{ menu_item('background-tasks', '/admin/background-tasks', 'collection', 'Background Tasks') }}
{{ menu_item('logs', '/admin/logs', 'document-text', 'Application Logs') }}
{{ menu_item('notifications', '/admin/notifications', 'bell', 'Notifications') }}
{% endcall %}
</div>
<!-- Platform Settings Section -->
{{ section_header('Platform Settings', 'settingsSection') }}
{% call section_content('settingsSection') %}
{{ menu_item('settings', '/admin/settings', 'cog', 'General') }}
{{ menu_item('email-templates', '/admin/email-templates', 'mail', 'Email Templates') }}
{# TODO: Implement profile and API keys pages #}
{# {{ menu_item('profile', '/admin/profile', 'user-circle', 'Profile') }} #}
{# {{ menu_item('api-keys', '/admin/api-keys', 'key', 'API Keys') }} #}
{% endcall %}
<div x-show="isSectionVisible('settingsSection')">
{{ section_header('Platform Settings', 'settingsSection') }}
{% call section_content('settingsSection') %}
{{ menu_item('settings', '/admin/settings', 'cog', 'General') }}
{{ menu_item('email-templates', '/admin/email-templates', 'mail', 'Email Templates') }}
<!-- My Menu - super admin only (customize personal sidebar) -->
<template x-if="isSuperAdmin">
<li x-show="isMenuItemVisible('my-menu')" class="relative px-6 py-3">
<span x-show="currentPage === 'my-menu'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'my-menu' ? 'text-gray-800 dark:text-gray-100' : ''"
href="/admin/my-menu">
<span x-html="$icon('view-grid')"></span>
<span class="ml-4">My Menu</span>
</a>
</li>
</template>
{# TODO: Implement profile and API keys pages #}
{# {{ menu_item('profile', '/admin/profile', 'user-circle', 'Profile') }} #}
{# {{ menu_item('api-keys', '/admin/api-keys', 'key', 'API Keys') }} #}
{% endcall %}
</div>
</div>
{% endmacro %}

View File

@@ -0,0 +1,200 @@
{# app/templates/admin/platform-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 %}Menu Configuration{% endblock %}
{% block alpine_data %}adminPlatformMenuConfig('{{ platform_code }}'){% endblock %}
{% block content %}
{{ page_header('Menu Configuration', back_url='/admin/platforms/' + platform_code) }}
{{ alert_dynamic(type='success', title='Success', message_var='successMessage', show_condition='successMessage') }}
{{ error_state('Error', show_condition='error') }}
<!-- Platform Info -->
<div class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="platform?.name || 'Loading...'"></h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
Configure which menu items are visible for admins and vendors on this platform.
</p>
</div>
<span class="px-3 py-1 text-sm font-medium rounded-full bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200" x-text="platform?.code?.toUpperCase()"></span>
</div>
</div>
<!-- Frontend Type Tabs -->
<div class="mb-6">
<div class="flex space-x-1 bg-gray-100 dark:bg-gray-700 rounded-lg p-1 w-fit">
<button
@click="frontendType = 'admin'; loadPlatformMenuConfig()"
:class="{
'bg-white dark:bg-gray-800 shadow': frontendType === 'admin',
'text-gray-600 dark:text-gray-400': frontendType !== 'admin'
}"
class="px-4 py-2 text-sm font-medium rounded-md transition-all"
>
<span x-html="$icon('shield', 'w-4 h-4 inline mr-2')"></span>
Admin Frontend
</button>
<button
@click="frontendType = 'vendor'; loadPlatformMenuConfig()"
:class="{
'bg-white dark:bg-gray-800 shadow': frontendType === 'vendor',
'text-gray-600 dark:text-gray-400': frontendType !== 'vendor'
}"
class="px-4 py-2 text-sm font-medium rounded-md transition-all"
>
<span x-html="$icon('shopping-bag', 'w-4 h-4 inline mr-2')"></span>
Vendor Frontend
</button>
</div>
</div>
<!-- Stats Cards -->
<div class="grid gap-4 mb-6 md:grid-cols-3">
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
<span x-html="$icon('view-grid', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Total Items</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="menuConfig?.total_items || 0"></p>
</div>
</div>
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Visible</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="menuConfig?.visible_items || 0"></p>
</div>
</div>
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-gray-500 bg-gray-100 rounded-full dark:text-gray-100 dark:bg-gray-600">
<span x-html="$icon('eye-off', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Hidden</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="menuConfig?.hidden_items || 0"></p>
</div>
</div>
</div>
<!-- Actions -->
<div class="mb-4 flex items-center justify-between">
<p class="text-sm text-gray-500 dark:text-gray-400">
Toggle visibility for menu items. Mandatory items cannot be hidden.
</p>
<div class="flex gap-2">
<button
@click="showAll()"
:disabled="saving"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600"
>
<span x-html="$icon('eye', 'w-4 h-4 mr-2')"></span>
Show All
</button>
<button
@click="resetToDefaults()"
:disabled="saving"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600"
>
<span x-html="$icon('eye-off', 'w-4 h-4 mr-2')"></span>
Hide All
</button>
</div>
</div>
<!-- Loading State -->
<div x-show="loading" class="flex items-center justify-center py-12 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<span x-html="$icon('refresh', 'w-8 h-8 animate-spin text-purple-600')"></span>
<span class="ml-3 text-gray-500 dark:text-gray-400">Loading menu configuration...</span>
</div>
<!-- Menu Items by Section -->
<div x-show="!loading" class="space-y-6">
<template x-for="section in groupedItems" :key="section.id">
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 overflow-hidden">
<!-- Section Header -->
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div class="flex items-center">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="section.label || 'Main'"></h3>
<span
x-show="section.isSuperAdminOnly"
class="ml-2 px-2 py-0.5 text-xs font-medium rounded bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200"
>
Super Admin Only
</span>
</div>
<span class="text-xs text-gray-500 dark:text-gray-400" x-text="`${section.visibleCount}/${section.items.length} visible`"></span>
</div>
</div>
<!-- Section Items -->
<div class="divide-y divide-gray-100 dark:divide-gray-700">
<template x-for="item in section.items" :key="item.id">
<div class="flex items-center justify-between px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/30">
<div class="flex items-center">
<span x-html="$icon(item.icon, 'w-5 h-5 text-gray-400 mr-3')"></span>
<div>
<p class="text-sm font-medium text-gray-700 dark:text-gray-200" x-text="item.label"></p>
<p class="text-xs text-gray-400 dark:text-gray-500" x-text="item.url"></p>
</div>
</div>
<div class="flex items-center gap-3">
<!-- Mandatory Badge -->
<span
x-show="item.is_mandatory"
class="px-2 py-0.5 text-xs font-medium rounded bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
>
Mandatory
</span>
<!-- Toggle Switch -->
<button
@click="toggleVisibility(item)"
:disabled="item.is_mandatory || saving"
:class="{
'bg-purple-600': item.is_visible,
'bg-gray-200 dark:bg-gray-600': !item.is_visible,
'opacity-50 cursor-not-allowed': item.is_mandatory
}"
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2"
role="switch"
:aria-checked="item.is_visible"
>
<span
:class="{
'translate-x-5': item.is_visible,
'translate-x-0': !item.is_visible
}"
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
></span>
</button>
</div>
</div>
</template>
</div>
</div>
</template>
<!-- Empty State -->
<div x-show="groupedItems.length === 0" class="bg-white rounded-lg shadow-xs dark:bg-gray-800 p-8 text-center">
<span x-html="$icon('view-grid', 'w-12 h-12 mx-auto text-gray-400')"></span>
<p class="mt-4 text-gray-500 dark:text-gray-400">No menu items configured for this frontend type.</p>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', path='admin/js/platform-menu-config.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,300 @@
# Session Note: Self-Contained Module Architecture
**Date:** 2026-01-26
**Plan Reference:** `docs/proposals/TEMP.md` (now this file)
**Previous Session:** `docs/proposals/SESSION_NOTE_2026-01-25_modular-platform-architecture.md`
---
## Summary
Transformed thin module wrappers into fully self-contained modules, using CMS as the pilot. Each self-contained module is an autonomous unit with its own services, models, schemas, templates, exceptions, and locales.
---
## Completed Phases
### Phase 1: Foundation
Created infrastructure for self-contained modules:
| File | Purpose |
|------|---------|
| `app/modules/contracts/` | Protocol definitions for cross-module dependencies |
| `app/templates_config.py` | Multi-directory template loader for module templates |
| `app/modules/base.py` | Enhanced ModuleDefinition with self-contained flags |
**Git tag:** `pre-modular-architecture`
### Phase 2: CMS Pilot (Full Self-Contained Module)
Migrated CMS to be the first fully self-contained module:
| Component | Location | Status |
|-----------|----------|--------|
| Services | `app/modules/cms/services/content_page_service.py` | ✅ |
| Models | `app/modules/cms/models/content_page.py` | ✅ |
| Schemas | `app/modules/cms/schemas/content_page.py` | ✅ |
| Exceptions | `app/modules/cms/exceptions.py` | ✅ |
| Locales | `app/modules/cms/locales/{en,fr,de,lb}.json` | ✅ |
| Templates | `app/modules/cms/templates/cms/{admin,vendor}/` | ✅ |
| Static | `app/modules/cms/static/{admin,vendor}/js/` | ✅ |
| Routes | `app/modules/cms/routes/{api,pages}/` | ✅ |
---
## CMS Module Structure
```
app/modules/cms/
├── __init__.py # Lazy getter to avoid circular imports
├── definition.py # ModuleDefinition with self-contained config
├── exceptions.py # CMSException, ContentPageNotFoundError
├── locales/
│ ├── en.json
│ ├── fr.json
│ ├── de.json
│ └── lb.json
├── models/
│ ├── __init__.py # Exports: ContentPage, MediaFile, ProductMedia
│ └── content_page.py # ContentPage model (canonical location)
├── routes/
│ ├── __init__.py
│ ├── admin.py # Admin router wrapper
│ ├── vendor.py # Vendor router wrapper
│ ├── api/
│ │ ├── admin.py # Admin API endpoints
│ │ ├── vendor.py # Vendor API endpoints
│ │ └── shop.py # Shop/public API endpoints
│ └── pages/
│ ├── admin.py # Admin page routes
│ └── vendor.py # Vendor page routes
├── schemas/
│ ├── __init__.py
│ └── content_page.py # Pydantic schemas
├── services/
│ ├── __init__.py
│ └── content_page_service.py
├── static/
│ ├── admin/js/
│ │ ├── content-pages.js
│ │ └── content-page-edit.js
│ └── vendor/js/
│ ├── content-pages.js
│ └── content-page-edit.js
└── templates/
└── cms/
├── admin/
│ ├── content-pages.html
│ └── content-page-edit.html
└── vendor/
├── content-pages.html
└── content-page-edit.html
```
---
## Key Patterns Established
### 1. Module-First Models
Models live in module folders and are dynamically loaded at startup:
```python
# app/modules/cms/models/content_page.py (canonical location)
from app.core.database import Base
class ContentPage(Base):
__tablename__ = "content_pages"
...
# models/database/__init__.py (dynamic loader)
def _discover_module_models():
for module_dir in sorted(modules_dir.iterdir()):
models_init = module_dir / "models" / "__init__.py"
if models_init.exists():
importlib.import_module(f"app.modules.{module_dir.name}.models")
_discover_module_models()
```
### 2. Shared Templates Instance
Route files must import from `app.templates_config`:
```python
# CORRECT
from app.templates_config import templates
# WRONG - creates local instance without module loaders
templates = Jinja2Templates(directory="app/templates")
```
### 3. Template Namespacing
Module templates use namespace prefix to avoid collisions:
```python
# Module templates at: app/modules/cms/templates/cms/admin/content-pages.html
# Rendered as:
templates.TemplateResponse("cms/admin/content-pages.html", ...)
```
### 4. Import Pattern
All code should import from module:
```python
# Models
from app.modules.cms.models import ContentPage
# Services
from app.modules.cms.services import content_page_service
# Exceptions
from app.modules.cms.exceptions import ContentPageNotFoundException
```
### 5. Lazy Imports for Circular Import Prevention
```python
# app/modules/cms/__init__.py
def get_cms_module():
"""Lazy getter for cms_module to avoid circular imports."""
from app.modules.cms.definition import cms_module
return cms_module
```
---
## Decisions Made
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Pilot Module | CMS | Simplest, minimal dependencies |
| Cross-Module Pattern | Protocol pattern | Type-safe interfaces |
| Timeline | Incremental | Alongside feature work |
| Backwards Compatibility | No shims | Pre-launch, can delete old files |
| Template Namespace | `{module}/admin/`, `{module}/vendor/` | Prevent collisions |
---
## Verification Completed
- [x] `python -c "from main import app"` succeeds
- [x] ContentPage model in `app/modules/cms/models/content_page.py`
- [x] Dynamic model loader in `models/database/__init__.py`
- [x] `content_pages` table in Base.metadata (67 total tables)
- [x] Template files in correct locations
- [x] Route files use shared templates instance
- [x] Admin CMS pages render correctly
- [x] Vendor CMS pages render correctly
---
## What Stays in Core vs Moves to Modules
### Core (Stays in Place)
| Component | Location | Reason |
|-----------|----------|--------|
| User, Vendor, Company, Platform models | `models/database/` | Foundational entities |
| Auth service | `app/services/` | Cross-cutting concern |
| Storage, Cache, Email services | `app/services/` | Infrastructure utilities |
| Base exceptions | `app/exceptions/` | Shared error types |
| Shared macros, partials | `app/templates/shared/` | Reusable UI components |
| API dependencies | `app/api/deps.py` | Auth, module access checks |
### Modules (Move to Self-Contained)
| Module | Services | Models | Status |
|--------|----------|--------|--------|
| cms | content_page, media | content_page, media | ✅ Complete |
| billing | billing, stripe, invoice, subscription | subscription, invoice, payment | Pending |
| inventory | inventory, inventory_transaction | inventory, inventory_transaction | Pending |
| orders | order, cart, order_item_exception | order, order_item_exception | Pending |
| marketplace | marketplace, marketplace_product, letzshop_export | marketplace_product, import_job | Pending |
| customers | customer, customer_address | customer | Pending |
| messaging | messaging, notification | message, notification | Pending |
| analytics | stats, capacity_forecast | (uses other models) | Pending |
| monitoring | background_tasks, test_runner, log | test_run, architecture_scan | Pending |
---
## Pending/Next Steps
### Phase 3: Simple Modules Migration
- [ ] Migrate analytics module
- [ ] Migrate monitoring module
- [ ] Migrate messaging module
- [ ] Migrate customers module
### Phase 4: Complex Modules Migration
- [ ] Migrate billing (with Stripe integration)
- [ ] Migrate inventory
- [ ] Migrate orders
- [ ] Migrate marketplace
### Phase 5: Cleanup
- [ ] Remove deprecated shims (if any created)
- [ ] Update all imports across codebase
- [ ] Delete `app/platforms/` directory
- [ ] Update architecture documentation
### Other Pending Items
- [ ] Wire up vendor module routers to `app/api/v1/vendor/__init__.py`
- [ ] PlatformModule database table (optional - for audit trail)
- [ ] Module-specific configuration UI
- [ ] Integration tests for `/api/v1/admin/modules/*` endpoints
---
## Git Commits
```
ec4ec04 feat: complete CMS as fully autonomous self-contained module
0b65864 fix: resolve circular import in CMS module
3ffa890 fix: correct static file mount order and update architecture validator
3307205 feat: add module info and configuration pages to admin panel
```
---
## Key Files Reference
### Self-Contained Module Infrastructure
- `app/modules/base.py` - ModuleDefinition with self-contained flags
- `app/modules/contracts/` - Protocol definitions for cross-module deps
- `app/templates_config.py` - Multi-directory template loader
- `models/database/__init__.py` - Dynamic module model discovery
### CMS Module (Pilot)
- `app/modules/cms/definition.py` - Module metadata
- `app/modules/cms/models/content_page.py` - ContentPage model
- `app/modules/cms/services/content_page_service.py` - Business logic
- `app/modules/cms/exceptions.py` - Module-specific exceptions
- `app/modules/cms/templates/cms/` - Namespaced templates
---
## Testing
```bash
# Verify app starts
python -c "from main import app; print('OK')"
# Run module service tests
python -m pytest tests/unit/services/test_module_service.py -v
# Verify CMS model is loaded
python -c "from app.modules.cms.models import ContentPage; print(ContentPage.__tablename__)"
# Verify template loading
python -c "from app.templates_config import templates; print(templates.env.loader)"
```

View File

@@ -1,557 +0,0 @@
● Platform Homepage Configuration Analysis
Summary: Current Flexibility
┌──────────────────────┬─────────────┬──────────────────────────────────────────────────┐
│ Feature │ Flexibility │ Notes │
├──────────────────────┼─────────────┼──────────────────────────────────────────────────┤
│ Homepage Content │ ✅ High │ Fully CMS-driven via ContentPage model │
├──────────────────────┼─────────────┼──────────────────────────────────────────────────┤
│ Header/Footer Menus │ ⚠️ Medium │ Driven by ContentPage flags, flat structure only │
├──────────────────────┼─────────────┼──────────────────────────────────────────────────┤
│ Platform Branding │ ✅ High │ Logo, favicon, colors via Platform model │
├──────────────────────┼─────────────┼──────────────────────────────────────────────────┤
│ Vendor Themes │ ✅ High │ 7 presets + custom CSS + full color control │
├──────────────────────┼─────────────┼──────────────────────────────────────────────────┤
│ Pricing/Features │ ❌ Low │ Hardcoded in TIER_LIMITS │
├──────────────────────┼─────────────┼──────────────────────────────────────────────────┤
│ Navigation Structure │ ❌ Low │ No nested menus, no icons │
└──────────────────────┴─────────────┴──────────────────────────────────────────────────┘
---
1. Homepage Content Configuration
Model: ContentPage with three-tier hierarchy
Platform Marketing Pages (is_platform_page=True, vendor_id=NULL)
↓ e.g., /pricing, /about, /features for oms.lu
Vendor Default Pages (is_platform_page=False, vendor_id=NULL)
↓ Fallback for all vendors (About, Shipping Policy, etc.)
Vendor Override Pages (is_platform_page=False, vendor_id=set)
↓ Vendor-specific customizations
Configurable per page:
- title, content (HTML/Markdown), slug
- template (default, minimal, modern, full)
- meta_description, meta_keywords (SEO)
- show_in_header, show_in_footer, show_in_legal
- display_order, is_published
---
2. Menu Configuration
Current approach: Content-driven (no separate Menu model)
┌───────────────┬─────────────┬─────────────────────┐
│ Menu Location │ Source │ Filter │
├───────────────┼─────────────┼─────────────────────┤
│ Header │ ContentPage │ show_in_header=True │
├───────────────┼─────────────┼─────────────────────┤
│ Footer │ ContentPage │ show_in_footer=True │
├───────────────┼─────────────┼─────────────────────┤
│ Legal bar │ ContentPage │ show_in_legal=True │
└───────────────┴─────────────┴─────────────────────┘
Limitations:
- Flat structure only (no dropdowns/submenus)
- No custom menu items (only links to content pages)
- No menu icons or special styling
- No external URLs
---
3. Platform Model
File: models/database/platform.py
Platform:
code # 'main', 'oms', 'loyalty'
name # Display name
domain # Production: 'oms.lu'
path_prefix # Dev: '/oms/'
logo # Light mode logo URL
logo_dark # Dark mode logo URL
favicon # Favicon URL
theme_config # JSON: colors, fonts, etc.
default_language # 'fr', 'en', 'de'
supported_languages # ['fr', 'de', 'en']
settings # JSON: feature flags
---
4. Theme System
Vendor-level only (not platform-level defaults)
┌───────────────┬────────┬─────────────────────────────────────────────────────────────┐
│ Property │ Type │ Options │
├───────────────┼────────┼─────────────────────────────────────────────────────────────┤
│ Colors │ JSON │ primary, secondary, accent, background, text, border │
├───────────────┼────────┼─────────────────────────────────────────────────────────────┤
│ Fonts │ String │ font_family_heading, font_family_body │
├───────────────┼────────┼─────────────────────────────────────────────────────────────┤
│ Layout │ String │ grid, list, masonry │
├───────────────┼────────┼─────────────────────────────────────────────────────────────┤
│ Header │ String │ fixed, static, transparent │
├───────────────┼────────┼─────────────────────────────────────────────────────────────┤
│ Product cards │ String │ modern, classic, minimal │
├───────────────┼────────┼─────────────────────────────────────────────────────────────┤
│ Custom CSS │ Text │ Injected into <style> │
├───────────────┼────────┼─────────────────────────────────────────────────────────────┤
│ Presets │ 7 │ default, modern, classic, minimal, vibrant, elegant, nature │
└───────────────┴────────┴─────────────────────────────────────────────────────────────┘
---
5. What's Hardcoded
┌──────────────────────────┬──────────────────────┬───────────────────────────┐
│ Item │ Location │ Issue │
├──────────────────────────┼──────────────────────┼───────────────────────────┤
│ Pricing tiers │ TIER_LIMITS constant │ Cannot edit via admin │
├──────────────────────────┼──────────────────────┼───────────────────────────┤
│ Add-on options │ Route handlers │ Hardcoded prices/features │
├──────────────────────────┼──────────────────────┼───────────────────────────┤
│ Platform name "Wizamart" │ Templates │ Not from database │
├──────────────────────────┼──────────────────────┼───────────────────────────┤
│ Hero section layout │ Tailwind classes │ Fixed structure │
├──────────────────────────┼──────────────────────┼───────────────────────────┤
│ Menu structure │ Code │ No nesting support │
└──────────────────────────┴──────────────────────┴───────────────────────────┘
---
6. Gaps for Multi-Platform Flexibility
1. No Platform-Level Theme Defaults
- VendorTheme is per-vendor only
- Each platform cannot define default vendor theme
2. No Menu Model
- Cannot create custom navigation structures
- No external links, no icons, no dropdowns
3. Limited Template Switching
- Only 4 homepage templates
- No way to create new templates via admin
4. Hardcoded Pricing
- TIER_LIMITS in code, not database
- Cannot customize per platform
---
Would you like me to:
1. Create a Platform Theme system - Allow platform-level theme defaults that vendors inherit
2. Create a Menu model - Support hierarchical menus with custom items, icons, external links
3. Move pricing to database - Make tiers configurable per platform
4. Add homepage block system - Reorderable sections (hero, features, CTA, testimonials)
Which enhancement would be most valuable for your use case?
*******************
Section-Based Homepage Management System
Problem Statement
Current homepage implementation has critical issues:
1. Hardcoded platform content - Migrations contain OMS/Loyalty/Main-specific HTML
2. Monolithic content storage - Entire page stored as HTML blob, can't edit sections individually
3. No admin control - Hero, features, pricing sections are hardcoded in templates
Solution: JSON-Based Section Architecture
Approach: Add sections JSON field to ContentPage
Why JSON field vs separate PageSection model:
- Simpler - no new tables, no joins, no N+1 queries
- Flexible - schema can evolve without migrations
- Atomic - save entire homepage in one transaction
- Follows existing pattern - VendorTheme already uses JSON for colors
---
Multi-Language Support
Option A: Language-Keyed Sections (Recommended)
Store all translations in one JSON structure:
{
"hero": {
"enabled": true,
"title": {
"en": "Welcome to Our Platform",
"fr": "Bienvenue sur notre plateforme",
"de": "Willkommen auf unserer Plattform"
},
"subtitle": {
"en": "Your success starts here",
"fr": "Votre succès commence ici",
"de": "Ihr Erfolg beginnt hier"
},
"buttons": [
{
"text": {"en": "Get Started", "fr": "Commencer", "de": "Loslegen"},
"url": "/signup",
"style": "primary"
}
]
}
}
Pros:
- Single page entry per platform (not 3 separate pages)
- Easy to see which translations are missing
- Atomic save of all language variants
- Admin can edit all languages in one form
Cons:
- Larger JSON payload
- Need helper function to extract current language
Option B: Separate Page Per Language
Create one ContentPage per language with same slug but different content:
- slug="home", language="en"
- slug="home", language="fr"
- slug="home", language="de"
Pros:
- Simpler JSON structure per page
- Can have different sections per language
Cons:
- More database entries
- Harder to keep in sync
- Need to add language column to ContentPage
Recommendation: Option A (Language-Keyed)
This keeps all translations together and matches how the platform already handles supported_languages on the Platform model.
Dynamic Language Support
Languages are NOT hardcoded. The system uses the platform's supported_languages setting:
# Platform model already has:
supported_languages = Column(JSON) # e.g., ["fr", "de", "en"]
default_language = Column(String) # e.g., "fr"
Schema with Dynamic i18n
class TranslatableText(BaseModel):
"""
Text field with translations stored as dict.
Keys are language codes from platform.supported_languages.
"""
translations: dict[str, str] = {} # {"fr": "...", "de": "...", "en": "..."}
def get(self, lang: str, default_lang: str = "fr") -> str:
"""Get translation with fallback to default language."""
return self.translations.get(lang) or self.translations.get(default_lang) or ""
class HeroButton(BaseModel):
text: TranslatableText
url: str
style: str = "primary"
class HeroSection(BaseModel):
enabled: bool = True
badge_text: Optional[TranslatableText] = None
title: TranslatableText
subtitle: TranslatableText
background_type: str = "gradient"
buttons: list[HeroButton] = []
Template Usage with Platform Languages
{# Language comes from platform settings #}
{% set lang = request.state.language or platform.default_language %}
{% set default_lang = platform.default_language %}
<h1>{{ hero.title.get(lang, default_lang) }}</h1>
<p>{{ hero.subtitle.get(lang, default_lang) }}</p>
Admin UI Language Tabs
The admin editor dynamically generates language tabs from platform.supported_languages:
// Fetch platform languages
const platform = await apiClient.get(`/admin/platforms/${platformCode}`);
const languages = platform.supported_languages; // ["fr", "de", "en"]
// Render language tabs dynamically
languages.forEach(lang => {
addLanguageTab(lang);
});
---
Implementation Plan
Phase 1: Database Changes
1.1 Add sections column to ContentPage
File: models/database/content_page.py
sections = Column(JSON, nullable=True, default=None)
1.2 Create migration
File: alembic/versions/xxx_add_sections_to_content_pages.py
- Add sections JSON column (nullable)
Phase 2: Schema Validation
2.1 Create Pydantic schemas with dynamic i18n
File: models/schema/homepage_sections.py (NEW)
from pydantic import BaseModel
from typing import Optional
class TranslatableText(BaseModel):
"""
Stores translations as dict with language codes as keys.
Language codes come from platform.supported_languages.
"""
translations: dict[str, str] = {}
def get(self, lang: str, default_lang: str = "fr") -> str:
"""Get text for language with fallback."""
return self.translations.get(lang) or self.translations.get(default_lang) or ""
class HeroButton(BaseModel):
text: TranslatableText
url: str
style: str = "primary" # primary, secondary, outline
class HeroSection(BaseModel):
enabled: bool = True
badge_text: Optional[TranslatableText] = None
title: TranslatableText = TranslatableText()
subtitle: TranslatableText = TranslatableText()
background_type: str = "gradient"
buttons: list[HeroButton] = []
class FeatureCard(BaseModel):
icon: str
title: TranslatableText
description: TranslatableText
class FeaturesSection(BaseModel):
enabled: bool = True
title: TranslatableText = TranslatableText()
subtitle: Optional[TranslatableText] = None
features: list[FeatureCard] = []
layout: str = "grid"
class PricingSection(BaseModel):
enabled: bool = True
title: TranslatableText = TranslatableText()
subtitle: Optional[TranslatableText] = None
use_subscription_tiers: bool = True # Pull from DB dynamically
class CTASection(BaseModel):
enabled: bool = True
title: TranslatableText = TranslatableText()
subtitle: Optional[TranslatableText] = None
buttons: list[HeroButton] = []
class HomepageSections(BaseModel):
hero: Optional[HeroSection] = None
features: Optional[FeaturesSection] = None
pricing: Optional[PricingSection] = None
cta: Optional[CTASection] = None
Phase 3: Template Changes
3.1 Create section partials
Directory: app/templates/platform/sections/ (NEW)
- _hero.html - Renders hero with language support
- _features.html - Renders features grid
- _pricing.html - Renders pricing (uses subscription_tiers from DB)
- _cta.html - Renders CTA section
3.2 Update homepage templates
File: app/templates/platform/homepage-default.html
{% set lang = request.state.language or platform.default_language or 'fr' %}
{% if page and page.sections %}
{{ render_hero(page.sections.hero, lang) }}
{{ render_features(page.sections.features, lang) }}
{{ render_pricing(page.sections.pricing, lang, tiers) }}
{{ render_cta(page.sections.cta, lang) }}
{% else %}
{# Placeholder for unconfigured homepage #}
{% endif %}
Phase 4: Service Layer
4.1 Add section methods to ContentPageService
File: app/services/content_page_service.py
- update_homepage_sections(db, page_id, sections, updated_by) - Validates and saves
- get_default_sections() - Returns empty section structure
Phase 5: Admin API
5.1 Add section endpoints
File: app/api/v1/admin/content_pages.py
- GET /{page_id}/sections - Get structured sections
- PUT /{page_id}/sections - Update all sections
- PUT /{page_id}/sections/{section_name} - Update single section
Phase 6: Remove Hardcoded Content from Migrations
6.1 Update OMS migration
File: alembic/versions/z4e5f6a7b8c9_add_multi_platform_support.py
- Remove oms_homepage_content variable
- Create homepage with empty sections structure instead
- Set is_published=False (admin configures before publishing)
6.2 Migration creates structure only
- Migrations should ONLY create empty structure
- Content is entered via admin UI in each language
Phase 7: Admin UI
7.1 Add section editor to content-page-edit
File: app/templates/admin/content-page-edit.html
- Add "Sections" tab for homepage pages
- Language tabs within each section (EN | FR | DE | LB)
- Form fields for each section type
- Enable/disable toggle per section
File: static/admin/js/content-page-edit.js
- Section editor logic
- Language tab switching
- Save sections via API
---
Critical Files to Modify
1. models/database/content_page.py - Add sections column
2. models/schema/homepage_sections.py - NEW: Pydantic schemas with i18n
3. app/services/content_page_service.py - Add section methods
4. app/api/v1/admin/content_pages.py - Add section endpoints
5. app/templates/platform/sections/ - NEW: Section partials
6. app/templates/platform/homepage-default.html - Use section partials
7. app/routes/platform_pages.py - Pass sections + language to context
8. alembic/versions/z4e5f6a7b8c9_*.py - Remove hardcoded content
9. app/templates/admin/content-page-edit.html - Section editor UI with language tabs
10. static/admin/js/content-page-edit.js - Section editor JS
---
Section JSON Schema Example (with dynamic i18n)
Languages in translations dict come from platform.supported_languages.
{
"hero": {
"enabled": true,
"badge_text": {
"translations": {
"fr": "Essai gratuit de 30 jours",
"de": "30 Tage kostenlos testen",
"en": "30-Day Free Trial"
}
},
"title": {
"translations": {
"fr": "Votre titre de plateforme ici",
"de": "Ihr Plattform-Titel hier",
"en": "Your Platform Headline Here"
}
},
"subtitle": {
"translations": {
"fr": "Une description convaincante de votre plateforme.",
"de": "Eine überzeugende Beschreibung Ihrer Plattform.",
"en": "A compelling description of your platform."
}
},
"background_type": "gradient",
"buttons": [
{
"text": {
"translations": {"fr": "Commencer", "de": "Loslegen", "en": "Get Started"}
},
"url": "/signup",
"style": "primary"
}
]
},
"features": {
"enabled": true,
"title": {
"translations": {
"fr": "Pourquoi nous choisir",
"de": "Warum uns wählen",
"en": "Why Choose Us"
}
},
"features": [
{
"icon": "lightning-bolt",
"title": {"translations": {"fr": "Rapide", "de": "Schnell", "en": "Fast"}},
"description": {"translations": {"fr": "Rapide et efficace.", "de": "Schnell und effizient.", "en": "Quick and efficient."}}
}
]
},
"pricing": {
"enabled": true,
"title": {
"translations": {
"fr": "Tarification simple",
"de": "Einfache Preise",
"en": "Simple Pricing"
}
},
"use_subscription_tiers": true
},
"cta": {
"enabled": true,
"title": {
"translations": {
"fr": "Prêt à commencer?",
"de": "Bereit anzufangen?",
"en": "Ready to Start?"
}
},
"buttons": [
{
"text": {
"translations": {"fr": "S'inscrire gratuitement", "de": "Kostenlos registrieren", "en": "Sign Up Free"}
},
"url": "/signup",
"style": "primary"
}
]
}
}
---
Migration Strategy (No Hardcoded Content)
When creating a platform homepage:
homepage = ContentPage(
platform_id=platform_id,
slug="home",
title="Homepage", # Generic
content="", # Empty - sections used instead
sections=get_default_sections(), # Empty structure with all languages
is_published=False, # Admin configures first
)
---
Verification Steps
1. Run migration to add sections column
2. Create a test homepage with sections via API (all languages)
3. Verify homepage renders correct language based on request
4. Test admin UI section editor with language tabs
5. Verify pricing section pulls from subscription_tiers
6. Test enable/disable toggle for each section
7. Test language fallback when translation is missing
---
Notes
- Languages are dynamic from platform.supported_languages (not hardcoded)
- Fallback uses platform.default_language
- Admin UI should allow partial translations (show warning indicator for missing)
- Plan saved for resumption tomorrow

View File

@@ -0,0 +1,433 @@
# Modular Platform Architecture - Design Plan
## Executive Summary
Design a modular architecture where platforms can enable/disable feature modules. This creates a hierarchy:
```
Global (SaaS Provider)
└── Platform (Business Product - OMS, Loyalty, etc.)
├── Modules (Enabled features - Billing, Marketplace, Inventory, etc.)
│ ├── Routes (API + Page routes)
│ ├── Services (Business logic)
│ ├── Menu Items (Sidebar entries)
│ └── Templates (UI components)
└── Frontends
├── Admin (Platform management)
├── Vendor (Vendor dashboard)
└── Customer (Storefront) - future
```
---
## Current State Analysis
### What Exists
| Component | Status | Location |
|-----------|--------|----------|
| Platform Model | ✅ Complete | `models/database/platform.py` |
| Platform Configs | ⚠️ Partial | `app/platforms/{oms,loyalty}/config.py` (routes/templates empty) |
| Feature Registry | ✅ Complete | `models/database/feature.py` (50+ features) |
| Feature Gating | ✅ Complete | `app/core/feature_gate.py` + `app/services/feature_service.py` |
| Subscription Tiers | ✅ Complete | `models/database/subscription.py` (tier→features mapping) |
| Menu System | ✅ Complete | `app/config/menu_registry.py` + `AdminMenuConfig` model |
| Platform Context | ✅ Complete | `middleware/platform_context.py` (domain/path detection) |
### Key Insight: Features vs Modules
**Current "Features"** = granular capabilities (e.g., `analytics_dashboard`, `letzshop_sync`)
- Assigned to subscription tiers
- Gated at API route level
- 50+ individual features
**Proposed "Modules"** = cohesive feature bundles (e.g., `billing`, `marketplace`, `inventory`)
- Enabled/disabled per platform
- Contains multiple features, routes, menu items
- ~10-15 modules total
---
## Proposed Architecture
### Module Definition
A **Module** is a self-contained unit of functionality:
```python
# app/modules/base.py
class ModuleDefinition:
"""Base class for all modules."""
# Identity
code: str # "billing", "marketplace", "inventory"
name: str # "Billing & Subscriptions"
description: str
# Dependencies
requires: list[str] = [] # Other module codes required
# Components
features: list[str] = [] # Feature codes this module provides
menu_items: dict[FrontendType, list[str]] = {} # Menu items per frontend
# Routes (registered dynamically)
admin_router: APIRouter | None = None
vendor_router: APIRouter | None = None
# Status
is_core: bool = False # Core modules cannot be disabled
```
### Module Registry
```python
# app/modules/registry.py
MODULES = {
# Core modules (always enabled)
"core": ModuleDefinition(
code="core",
name="Core Platform",
is_core=True,
features=["dashboard", "settings", "profile"],
menu_items={
FrontendType.ADMIN: ["dashboard", "settings"],
FrontendType.VENDOR: ["dashboard", "settings"],
},
),
# Optional modules
"billing": ModuleDefinition(
code="billing",
name="Billing & Subscriptions",
features=["subscription_management", "billing_history", "stripe_integration"],
menu_items={
FrontendType.ADMIN: ["subscription-tiers", "subscriptions", "billing-history"],
FrontendType.VENDOR: ["billing"],
},
admin_router=billing_admin_router,
vendor_router=billing_vendor_router,
),
"marketplace": ModuleDefinition(
code="marketplace",
name="Marketplace (Letzshop)",
requires=["inventory"], # Depends on inventory module
features=["letzshop_sync", "marketplace_import"],
menu_items={
FrontendType.ADMIN: ["marketplace-letzshop"],
FrontendType.VENDOR: ["letzshop", "marketplace"],
},
),
"inventory": ModuleDefinition(
code="inventory",
name="Inventory Management",
features=["inventory_basic", "inventory_locations", "low_stock_alerts"],
menu_items={
FrontendType.ADMIN: ["inventory"],
FrontendType.VENDOR: ["inventory"],
},
),
# ... more modules
}
```
### Proposed Modules
| Module | Description | Features | Core? |
|--------|-------------|----------|-------|
| `core` | Dashboard, Settings, Profile | 3 | Yes |
| `platform-admin` | Companies, Vendors, Admin Users | 5 | Yes |
| `billing` | Subscriptions, Tiers, Billing History | 4 | No |
| `inventory` | Stock management, locations, alerts | 5 | No |
| `orders` | Order management, fulfillment | 6 | No |
| `marketplace` | Letzshop integration, import | 3 | No |
| `customers` | Customer management, CRM | 4 | No |
| `cms` | Content pages, media library | 6 | No |
| `analytics` | Dashboard, reports, exports | 4 | No |
| `messaging` | Internal messages, notifications | 3 | No |
| `dev-tools` | Components, icons (internal) | 2 | No |
| `monitoring` | Logs, background tasks, imports | 4 | No |
---
## Database Changes
### Option A: JSON Field (Simpler)
Use existing `Platform.settings` JSON field:
```python
# Platform.settings example
{
"enabled_modules": ["core", "billing", "inventory", "orders"],
"module_config": {
"billing": {"stripe_mode": "live"},
"inventory": {"low_stock_threshold": 10}
}
}
```
**Pros:** No migration needed, flexible
**Cons:** No referential integrity, harder to query
### Option B: Junction Table (Recommended)
New `PlatformModule` model:
```python
# models/database/platform_module.py
class PlatformModule(Base, TimestampMixin):
"""Module enablement per platform."""
__tablename__ = "platform_modules"
id = Column(Integer, primary_key=True)
platform_id = Column(Integer, ForeignKey("platforms.id"), nullable=False)
module_code = Column(String(50), nullable=False)
is_enabled = Column(Boolean, default=True)
config = Column(JSON, default={}) # Module-specific config
enabled_at = Column(DateTime)
enabled_by_user_id = Column(Integer, ForeignKey("users.id"))
__table_args__ = (
UniqueConstraint("platform_id", "module_code"),
)
```
**Pros:** Proper normalization, audit trail, queryable
**Cons:** Requires migration
---
## Implementation Phases
### Phase 1: Module Registry (No DB Changes)
1. Create `app/modules/` directory structure:
```
app/modules/
├── __init__.py
├── base.py # ModuleDefinition class
├── registry.py # MODULES dict
└── service.py # ModuleService
```
2. Define all modules in registry (data only, no behavior change)
3. Create `ModuleService`:
```python
class ModuleService:
def get_platform_modules(platform_id: int) -> list[str]
def is_module_enabled(platform_id: int, module_code: str) -> bool
def get_module_menu_items(platform_id: int, frontend_type: FrontendType) -> list[str]
```
4. Initially read from `Platform.settings["enabled_modules"]` (Option A)
### Phase 2: Integrate with Menu System
1. Update `MenuService.get_menu_for_rendering()`:
- Filter menu items based on enabled modules
- Module-disabled items don't appear (not just hidden)
2. Update `AdminMenuConfig` logic:
- Can only configure visibility for module-enabled items
- Module-disabled items are completely removed
### Phase 3: Database Model (Optional)
1. Create `PlatformModule` model
2. Migration to create table
3. Migrate data from `Platform.settings["enabled_modules"]`
4. Update `ModuleService` to use new table
### Phase 4: Dynamic Route Registration
1. Modify `app/api/v1/admin/__init__.py`:
```python
def register_module_routes(app: FastAPI, platform_code: str):
enabled_modules = module_service.get_enabled_modules(platform_code)
for module in enabled_modules:
if module.admin_router:
app.include_router(module.admin_router)
```
2. Add module check middleware for routes
### Phase 5: Admin UI for Module Management
1. Create `/admin/platforms/{code}/modules` page
2. Toggle modules on/off per platform
3. Show module dependencies
4. Module-specific configuration
---
## Directory Structure Evolution
### Current
```
app/
├── api/v1/
│ ├── admin/ # All admin routes mixed
│ └── vendor/ # All vendor routes mixed
├── platforms/
│ ├── oms/config.py # Platform config only
│ └── loyalty/config.py
└── services/ # All services mixed
```
### Proposed (Gradual Migration)
```
app/
├── modules/
│ ├── base.py
│ ├── registry.py
│ ├── service.py
│ ├── core/ # Core module
│ │ ├── __init__.py
│ │ └── definition.py
│ ├── billing/ # Billing module
│ │ ├── __init__.py
│ │ ├── definition.py
│ │ ├── routes/
│ │ │ ├── admin.py
│ │ │ └── vendor.py
│ │ └── services/
│ │ └── subscription_service.py
│ ├── marketplace/ # Marketplace module
│ │ └── ...
│ └── inventory/ # Inventory module
│ └── ...
├── api/v1/ # Legacy routes (gradually migrate)
└── platforms/ # Platform-specific overrides
├── oms/
└── loyalty/
```
---
## Key Design Decisions Needed
### 1. Migration Strategy
| Option | Description |
|--------|-------------|
| **A: Big Bang** | Move all code to modules at once |
| **B: Gradual** | Keep existing structure, modules are metadata only initially |
| **C: Hybrid** | New features in modules, migrate existing over time |
**Recommendation:** Option C (Hybrid) - Start with module definitions as metadata, then gradually move code.
### 2. Module Granularity
| Option | Example |
|--------|---------|
| **Coarse** | 5-8 large modules (billing, operations, content) |
| **Medium** | 10-15 medium modules (billing, inventory, orders, cms) |
| **Fine** | 20+ small modules (subscription-tiers, invoices, stock-levels) |
**Recommendation:** Medium granularity - matches current menu sections.
### 3. Core vs Optional
Which modules should be mandatory (cannot be disabled)?
**Proposed Core:**
- `core` (dashboard, settings)
- `platform-admin` (companies, vendors, admin-users)
**Everything else optional** (including billing - some platforms may not charge).
---
## Relationship to Existing Systems
### Modules → Features
- Module contains multiple features
- Enabling module makes its features available for tier assignment
- Features still gated by subscription tier
### Modules → Menu Items
- Module specifies which menu items it provides
- Menu items only visible if module enabled AND menu visibility allows
### Modules → Routes
- Module can provide admin and vendor routers
- Routes only registered if module enabled
- Existing `require_menu_access()` still applies
### Platform Config → Modules
- `app/platforms/oms/config.py` can specify default modules
- Database `PlatformModule` or `Platform.settings` overrides defaults
---
## Verification Plan
1. **Module definition only (Phase 1)**
- Define all modules in registry
- Add `enabled_modules` to Platform.settings
- Verify ModuleService returns correct modules
2. **Menu integration (Phase 2)**
- Disable "billing" module for Loyalty platform
- Verify billing menu items don't appear in sidebar
- Verify `/admin/subscriptions` returns 404 or redirect
3. **Full module isolation (Phase 4)**
- Create new platform with minimal modules
- Verify only enabled module routes are accessible
- Verify module dependencies are enforced
---
## Decisions Made
| Decision | Choice | Rationale |
|----------|--------|-----------|
| **Storage** | JSON field (`Platform.settings`) | No migration needed, can upgrade to table later |
| **Migration** | Gradual | Module definitions as metadata first, migrate code over time |
| **Billing** | Optional module | Some platforms may not charge (e.g., internal loyalty) |
| **First module** | `billing` | Self-contained, clear routes/services, good isolation test |
---
## Implementation Roadmap
### Immediate (Phase 1): Module Foundation
1. Create `app/modules/` directory with base classes
2. Define module registry with all ~12 modules
3. Create `ModuleService` reading from `Platform.settings`
4. Add `enabled_modules` to OMS and Loyalty platform settings
### Next (Phase 2): Menu Integration
1. Update `MenuService` to filter by enabled modules
2. Test: Disable billing module → billing menu items disappear
### Then (Phase 3): Billing Module Extraction
1. Create `app/modules/billing/` structure
2. Move billing routes and services into module
3. Register billing routes dynamically based on module status
### Future: Additional Modules
- Extract marketplace, inventory, orders, etc.
- Consider junction table if audit trail becomes important
---
## Files to Create/Modify
| File | Action | Purpose |
|------|--------|---------|
| `app/modules/__init__.py` | CREATE | Module package init |
| `app/modules/base.py` | CREATE | ModuleDefinition dataclass |
| `app/modules/registry.py` | CREATE | MODULES dict with all definitions |
| `app/modules/service.py` | CREATE | ModuleService class |
| `app/services/menu_service.py` | MODIFY | Filter by enabled modules |
| `app/platforms/oms/config.py` | MODIFY | Add enabled_modules |
| `app/platforms/loyalty/config.py` | MODIFY | Add enabled_modules |

View File

@@ -0,0 +1,244 @@
# models/database/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
"""
import enum
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
class FrontendType(str, enum.Enum):
"""Frontend types that can have menu configuration."""
ADMIN = "admin" # Admin panel (super admins, platform admins)
VENDOR = "vendor" # Vendor dashboard
# Menu items that cannot be hidden - always visible regardless of config
# Organized by frontend type
MANDATORY_MENU_ITEMS = {
FrontendType.ADMIN: frozenset({
"dashboard", # Default landing page after login
"companies",
"vendors",
"admin-users",
"settings",
"my-menu", # Super admin menu config - must always be accessible
}),
FrontendType.VENDOR: frozenset({
"dashboard", # Default landing page after login
"settings",
}),
}
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})>"
)

View File

@@ -73,6 +73,15 @@ class User(Base, TimestampMixin):
cascade="all, delete-orphan",
)
# Menu visibility configuration (for super admins only)
# Platform admins get menu config from their platform, not user-level
menu_configs = relationship(
"AdminMenuConfig",
foreign_keys="AdminMenuConfig.user_id",
back_populates="user",
cascade="all, delete-orphan",
)
def __repr__(self):
"""String representation of the User object."""
return f"<User(id={self.id}, username='{self.username}', email='{self.email}', role='{self.role}')>"

View File

@@ -212,6 +212,69 @@ function data() {
get isSuperAdmin() {
return this.adminProfile?.is_super_admin === true;
},
// ─────────────────────────────────────────────────────────────────
// Dynamic menu visibility (loaded from API)
// ─────────────────────────────────────────────────────────────────
menuData: null,
menuLoading: false,
visibleMenuItems: new Set(),
async loadMenuConfig(forceReload = false) {
// Don't reload if already loaded (unless forced)
if (!forceReload && (this.menuData || this.menuLoading)) return;
// Skip if apiClient is not available (e.g., on login page)
if (typeof apiClient === 'undefined') {
console.debug('Menu config: apiClient not available');
return;
}
// Skip if not authenticated
if (!localStorage.getItem('admin_token')) {
console.debug('Menu config: no admin_token, skipping');
return;
}
this.menuLoading = true;
try {
this.menuData = await apiClient.get('/admin/menu-config/render/admin');
// Build a set of visible menu item IDs for quick lookup
this.visibleMenuItems = new Set();
for (const section of (this.menuData?.sections || [])) {
for (const item of (section.items || [])) {
this.visibleMenuItems.add(item.id);
}
}
console.debug('Menu config loaded:', this.visibleMenuItems.size, 'items');
} catch (e) {
// Silently fail - menu will show all items as fallback
console.debug('Menu config not loaded, using defaults:', e?.message || e);
} finally {
this.menuLoading = false;
}
},
async reloadSidebarMenu() {
// Force reload the sidebar menu config
this.menuData = null;
this.visibleMenuItems = new Set();
await this.loadMenuConfig(true);
},
isMenuItemVisible(menuItemId) {
// If menu not loaded yet, show all items (fallback to hardcoded)
if (!this.menuData) return true;
return this.visibleMenuItems.has(menuItemId);
},
isSectionVisible(sectionId) {
// If menu not loaded yet, show all sections
if (!this.menuData) return true;
// Check if any item in this section is visible
const section = this.menuData?.sections?.find(s => s.id === sectionId);
return section && section.items && section.items.length > 0;
}
}
}