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:
128
alembic/versions/za0k1l2m3n4o5_add_admin_menu_config.py
Normal file
128
alembic/versions/za0k1l2m3n4o5_add_admin_menu_config.py
Normal 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")
|
||||||
@@ -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
18
app/config/__init__.py
Normal 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
546
app/config/menu_registry.py
Normal 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
|
||||||
@@ -20,7 +20,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.exceptions.platform import (
|
from app.exceptions.platform import (
|
||||||
PlatformNotFoundException,
|
PlatformNotFoundException,
|
||||||
)
|
)
|
||||||
from models.database.content_page import ContentPage
|
from app.modules.cms.models import ContentPage
|
||||||
from models.database.platform import Platform
|
from models.database.platform import Platform
|
||||||
from models.database.vendor_platform import VendorPlatform
|
from models.database.vendor_platform import VendorPlatform
|
||||||
|
|
||||||
@@ -81,6 +81,28 @@ class PlatformService:
|
|||||||
"""
|
"""
|
||||||
return db.query(Platform).filter(Platform.code == code).first()
|
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
|
@staticmethod
|
||||||
def list_platforms(
|
def list_platforms(
|
||||||
db: Session, include_inactive: bool = False
|
db: Session, include_inactive: bool = False
|
||||||
|
|||||||
174
app/templates/admin/my-menu-config.html
Normal file
174
app/templates/admin/my-menu-config.html
Normal 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 %}
|
||||||
@@ -38,9 +38,9 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{# Macro for menu item #}
|
{# Macro for menu item with visibility check #}
|
||||||
{% macro menu_item(page_id, url, icon, label) %}
|
{% 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>
|
<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"
|
<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' : ''"
|
:class="currentPage === '{{ page_id }}' ? 'text-gray-800 dark:text-gray-100' : ''"
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
============================================================================ #}
|
============================================================================ #}
|
||||||
|
|
||||||
{% macro sidebar_content() %}
|
{% 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">
|
<a class="ml-6 text-lg font-bold text-gray-800 dark:text-gray-200" href="/admin/dashboard">
|
||||||
Admin Portal
|
Admin Portal
|
||||||
</a>
|
</a>
|
||||||
@@ -77,80 +77,110 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Platform Administration Section -->
|
<!-- Platform Administration Section -->
|
||||||
{{ section_header('Platform Administration', 'platformAdmin') }}
|
<div x-show="isSectionVisible('platformAdmin')">
|
||||||
{% call section_content('platformAdmin') %}
|
{{ section_header('Platform Administration', 'platformAdmin') }}
|
||||||
{{ menu_item('companies', '/admin/companies', 'office-building', 'Companies') }}
|
{% call section_content('platformAdmin') %}
|
||||||
{{ menu_item('vendors', '/admin/vendors', 'shopping-bag', 'Vendors') }}
|
{{ menu_item('companies', '/admin/companies', 'office-building', 'Companies') }}
|
||||||
{{ menu_item('messages', '/admin/messages', 'chat-bubble-left-right', 'Messages') }}
|
{{ menu_item('vendors', '/admin/vendors', 'shopping-bag', 'Vendors') }}
|
||||||
{% endcall %}
|
{{ menu_item('messages', '/admin/messages', 'chat-bubble-left-right', 'Messages') }}
|
||||||
|
{% endcall %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Vendor Operations Section -->
|
<!-- Vendor Operations Section -->
|
||||||
{{ section_header('Vendor Operations', 'vendorOps') }}
|
<div x-show="isSectionVisible('vendorOps')">
|
||||||
{% call section_content('vendorOps') %}
|
{{ section_header('Vendor Operations', 'vendorOps') }}
|
||||||
{{ menu_item('vendor-products', '/admin/vendor-products', 'cube', 'Products') }}
|
{% call section_content('vendorOps') %}
|
||||||
{{ menu_item('customers', '/admin/customers', 'user-group', 'Customers') }}
|
{{ menu_item('vendor-products', '/admin/vendor-products', 'cube', 'Products') }}
|
||||||
{{ menu_item('inventory', '/admin/inventory', 'archive', 'Inventory') }}
|
{{ menu_item('customers', '/admin/customers', 'user-group', 'Customers') }}
|
||||||
{{ menu_item('orders', '/admin/orders', 'clipboard-list', 'Orders') }}
|
{{ menu_item('inventory', '/admin/inventory', 'archive', 'Inventory') }}
|
||||||
{# Future items - uncomment when implemented:
|
{{ menu_item('orders', '/admin/orders', 'clipboard-list', 'Orders') }}
|
||||||
{{ menu_item('shipping', '/admin/shipping', 'truck', 'Shipping') }}
|
{# Future items - uncomment when implemented:
|
||||||
#}
|
{{ menu_item('shipping', '/admin/shipping', 'truck', 'Shipping') }}
|
||||||
{% endcall %}
|
#}
|
||||||
|
{% endcall %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Marketplace Section -->
|
<!-- Marketplace Section -->
|
||||||
{{ section_header('Marketplace', 'marketplace') }}
|
<div x-show="isSectionVisible('marketplace')">
|
||||||
{% call section_content('marketplace') %}
|
{{ section_header('Marketplace', 'marketplace') }}
|
||||||
{{ menu_item('marketplace-letzshop', '/admin/marketplace/letzshop', 'shopping-cart', 'Letzshop') }}
|
{% call section_content('marketplace') %}
|
||||||
{% endcall %}
|
{{ menu_item('marketplace-letzshop', '/admin/marketplace/letzshop', 'shopping-cart', 'Letzshop') }}
|
||||||
|
{% endcall %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Billing & Subscriptions Section -->
|
<!-- Billing & Subscriptions Section -->
|
||||||
{{ section_header('Billing & Subscriptions', 'billing') }}
|
<div x-show="isSectionVisible('billing')">
|
||||||
{% call section_content('billing') %}
|
{{ section_header('Billing & Subscriptions', 'billing') }}
|
||||||
{{ menu_item('subscription-tiers', '/admin/subscription-tiers', 'tag', 'Subscription Tiers') }}
|
{% call section_content('billing') %}
|
||||||
{{ menu_item('subscriptions', '/admin/subscriptions', 'credit-card', 'Vendor Subscriptions') }}
|
{{ menu_item('subscription-tiers', '/admin/subscription-tiers', 'tag', 'Subscription Tiers') }}
|
||||||
{{ menu_item('billing-history', '/admin/billing-history', 'document-text', 'Billing History') }}
|
{{ menu_item('subscriptions', '/admin/subscriptions', 'credit-card', 'Vendor Subscriptions') }}
|
||||||
{% endcall %}
|
{{ menu_item('billing-history', '/admin/billing-history', 'document-text', 'Billing History') }}
|
||||||
|
{% endcall %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Content Management Section -->
|
<!-- Content Management Section -->
|
||||||
{{ section_header('Content Management', 'contentMgmt') }}
|
<div x-show="isSectionVisible('contentMgmt')">
|
||||||
{% call section_content('contentMgmt') %}
|
{{ section_header('Content Management', 'contentMgmt') }}
|
||||||
{{ menu_item('platforms', '/admin/platforms', 'globe-alt', 'Platforms') }}
|
{% call section_content('contentMgmt') %}
|
||||||
{{ menu_item('content-pages', '/admin/content-pages', 'document-text', 'Content Pages') }}
|
{{ menu_item('platforms', '/admin/platforms', 'globe-alt', 'Platforms') }}
|
||||||
{{ menu_item('vendor-theme', '/admin/vendor-themes', 'color-swatch', 'Vendor Themes') }}
|
{{ menu_item('content-pages', '/admin/content-pages', 'document-text', 'Content Pages') }}
|
||||||
{% endcall %}
|
{{ menu_item('vendor-themes', '/admin/vendor-themes', 'color-swatch', 'Vendor Themes') }}
|
||||||
|
{% endcall %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Developer Tools Section -->
|
<!-- Developer Tools Section -->
|
||||||
{{ section_header('Developer Tools', 'devTools') }}
|
<div x-show="isSectionVisible('devTools')">
|
||||||
{% call section_content('devTools') %}
|
{{ section_header('Developer Tools', 'devTools') }}
|
||||||
{{ menu_item('components', '/admin/components', 'view-grid', 'Components') }}
|
{% call section_content('devTools') %}
|
||||||
{{ menu_item('icons', '/admin/icons', 'photograph', 'Icons') }}
|
{{ menu_item('components', '/admin/components', 'view-grid', 'Components') }}
|
||||||
{% endcall %}
|
{{ menu_item('icons', '/admin/icons', 'photograph', 'Icons') }}
|
||||||
|
{% endcall %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Platform Health Section -->
|
<!-- Platform Health Section -->
|
||||||
{{ section_header('Platform Health', 'platformHealth') }}
|
<div x-show="isSectionVisible('platformHealth')">
|
||||||
{% call section_content('platformHealth') %}
|
{{ section_header('Platform Health', 'platformHealth') }}
|
||||||
{{ menu_item('platform-health', '/admin/platform-health', 'chart-bar', 'Capacity Monitor') }}
|
{% call section_content('platformHealth') %}
|
||||||
{{ menu_item('testing', '/admin/testing', 'beaker', 'Testing Hub') }}
|
{{ menu_item('platform-health', '/admin/platform-health', 'chart-bar', 'Capacity Monitor') }}
|
||||||
{{ menu_item('code-quality', '/admin/code-quality', 'shield-check', 'Code Quality') }}
|
{{ menu_item('testing', '/admin/testing', 'beaker', 'Testing Hub') }}
|
||||||
{% endcall %}
|
{{ menu_item('code-quality', '/admin/code-quality', 'shield-check', 'Code Quality') }}
|
||||||
|
{% endcall %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Platform Monitoring Section -->
|
<!-- Platform Monitoring Section -->
|
||||||
{{ section_header('Platform Monitoring', 'monitoring') }}
|
<div x-show="isSectionVisible('monitoring')">
|
||||||
{% call section_content('monitoring') %}
|
{{ section_header('Platform Monitoring', 'monitoring') }}
|
||||||
{{ menu_item('imports', '/admin/imports', 'cube', 'Import Jobs') }}
|
{% call section_content('monitoring') %}
|
||||||
{{ menu_item('background-tasks', '/admin/background-tasks', 'collection', 'Background Tasks') }}
|
{{ menu_item('imports', '/admin/imports', 'cube', 'Import Jobs') }}
|
||||||
{{ menu_item('logs', '/admin/logs', 'document-text', 'Application Logs') }}
|
{{ menu_item('background-tasks', '/admin/background-tasks', 'collection', 'Background Tasks') }}
|
||||||
{{ menu_item('notifications', '/admin/notifications', 'bell', 'Notifications') }}
|
{{ menu_item('logs', '/admin/logs', 'document-text', 'Application Logs') }}
|
||||||
{% endcall %}
|
{{ menu_item('notifications', '/admin/notifications', 'bell', 'Notifications') }}
|
||||||
|
{% endcall %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Platform Settings Section -->
|
<!-- Platform Settings Section -->
|
||||||
{{ section_header('Platform Settings', 'settingsSection') }}
|
<div x-show="isSectionVisible('settingsSection')">
|
||||||
{% call section_content('settingsSection') %}
|
{{ section_header('Platform Settings', 'settingsSection') }}
|
||||||
{{ menu_item('settings', '/admin/settings', 'cog', 'General') }}
|
{% call section_content('settingsSection') %}
|
||||||
{{ menu_item('email-templates', '/admin/email-templates', 'mail', 'Email Templates') }}
|
{{ menu_item('settings', '/admin/settings', 'cog', 'General') }}
|
||||||
{# TODO: Implement profile and API keys pages #}
|
{{ menu_item('email-templates', '/admin/email-templates', 'mail', 'Email Templates') }}
|
||||||
{# {{ menu_item('profile', '/admin/profile', 'user-circle', 'Profile') }} #}
|
<!-- My Menu - super admin only (customize personal sidebar) -->
|
||||||
{# {{ menu_item('api-keys', '/admin/api-keys', 'key', 'API Keys') }} #}
|
<template x-if="isSuperAdmin">
|
||||||
{% endcall %}
|
<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>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
|||||||
200
app/templates/admin/platform-menu-config.html
Normal file
200
app/templates/admin/platform-menu-config.html
Normal 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 %}
|
||||||
300
docs/proposals/SESSION_NOTE_2026-01-26_self-contained-modules.md
Normal file
300
docs/proposals/SESSION_NOTE_2026-01-26_self-contained-modules.md
Normal 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)"
|
||||||
|
```
|
||||||
@@ -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
|
|
||||||
433
docs/proposals/humble-orbiting-otter.md
Normal file
433
docs/proposals/humble-orbiting-otter.md
Normal 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 |
|
||||||
|
|
||||||
244
models/database/admin_menu_config.py
Normal file
244
models/database/admin_menu_config.py
Normal 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})>"
|
||||||
|
)
|
||||||
@@ -73,6 +73,15 @@ class User(Base, TimestampMixin):
|
|||||||
cascade="all, delete-orphan",
|
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):
|
def __repr__(self):
|
||||||
"""String representation of the User object."""
|
"""String representation of the User object."""
|
||||||
return f"<User(id={self.id}, username='{self.username}', email='{self.email}', role='{self.role}')>"
|
return f"<User(id={self.id}, username='{self.username}', email='{self.email}', role='{self.role}')>"
|
||||||
|
|||||||
@@ -212,6 +212,69 @@ function data() {
|
|||||||
|
|
||||||
get isSuperAdmin() {
|
get isSuperAdmin() {
|
||||||
return this.adminProfile?.is_super_admin === true;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user