refactor: migrate templates and static files to self-contained modules

Templates Migration:
- Migrate admin templates to modules (tenancy, billing, monitoring, marketplace, etc.)
- Migrate vendor templates to modules (tenancy, billing, orders, messaging, etc.)
- Migrate storefront templates to modules (catalog, customers, orders, cart, checkout, cms)
- Migrate public templates to modules (billing, marketplace, cms)
- Keep shared templates in app/templates/ (base.html, errors/, partials/, macros/)
- Migrate letzshop partials to marketplace module

Static Files Migration:
- Migrate admin JS to modules: tenancy (23 files), core (5 files), monitoring (1 file)
- Migrate vendor JS to modules: tenancy (4 files), core (2 files)
- Migrate shared JS: vendor-selector.js to core, media-picker.js to cms
- Migrate storefront JS: storefront-layout.js to core
- Keep framework JS in static/ (api-client, utils, money, icons, log-config, lib/)
- Update all template references to use module_static paths

Naming Consistency:
- Rename static/platform/ to static/public/
- Rename app/templates/platform/ to app/templates/public/
- Update all extends and static references

Documentation:
- Update module-system.md with shared templates documentation
- Update frontend-structure.md with new module JS organization

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 14:34:16 +01:00
parent 843703258f
commit 4e28d91a78
542 changed files with 11603 additions and 9037 deletions

View File

@@ -2,11 +2,16 @@
"""
Core module API routes.
Admin routes:
- /dashboard/* - Admin dashboard and statistics
- /settings/* - Platform settings management
Vendor routes:
- /dashboard/* - Dashboard statistics
- /settings/* - Vendor settings management
"""
from .admin import admin_router
from .vendor import vendor_router
__all__ = ["vendor_router"]
__all__ = ["admin_router", "vendor_router"]

View File

@@ -0,0 +1,19 @@
# app/modules/core/routes/api/admin.py
"""
Core module admin API routes.
Aggregates all admin core routes:
- /dashboard/* - Admin dashboard and statistics
- /settings/* - Platform settings management
"""
from fastapi import APIRouter
from .admin_dashboard import admin_dashboard_router
from .admin_settings import admin_settings_router
admin_router = APIRouter()
# Aggregate all core admin routes
admin_router.include_router(admin_dashboard_router, tags=["admin-dashboard"])
admin_router.include_router(admin_settings_router, tags=["admin-settings"])

View File

@@ -0,0 +1,127 @@
# app/modules/core/routes/api/admin_dashboard.py
"""
Admin dashboard and statistics endpoints.
"""
import logging
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.modules.tenancy.services.admin_service import admin_service
from app.modules.analytics.services.stats_service import stats_service
from models.schema.auth import UserContext
from app.modules.analytics.schemas import (
AdminDashboardResponse,
ImportStatsResponse,
MarketplaceStatsResponse,
OrderStatsBasicResponse,
PlatformStatsResponse,
ProductStatsResponse,
StatsResponse,
UserStatsResponse,
VendorStatsResponse,
)
admin_dashboard_router = APIRouter(prefix="/dashboard")
logger = logging.getLogger(__name__)
@admin_dashboard_router.get("", response_model=AdminDashboardResponse)
def get_admin_dashboard(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get admin dashboard with platform statistics (Admin only)."""
user_stats = stats_service.get_user_statistics(db)
vendor_stats = stats_service.get_vendor_statistics(db)
return AdminDashboardResponse(
platform={
"name": "Multi-Tenant Ecommerce Platform",
"version": "1.0.0",
},
users=UserStatsResponse(**user_stats),
vendors=VendorStatsResponse(
total=vendor_stats.get("total", vendor_stats.get("total_vendors", 0)),
verified=vendor_stats.get(
"verified", vendor_stats.get("verified_vendors", 0)
),
pending=vendor_stats.get("pending", vendor_stats.get("pending_vendors", 0)),
inactive=vendor_stats.get(
"inactive", vendor_stats.get("inactive_vendors", 0)
),
),
recent_vendors=admin_service.get_recent_vendors(db, limit=5),
recent_imports=admin_service.get_recent_import_jobs(db, limit=10),
)
@admin_dashboard_router.get("/stats", response_model=StatsResponse)
def get_comprehensive_stats(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get comprehensive platform statistics (Admin only)."""
stats_data = stats_service.get_comprehensive_stats(db=db)
return StatsResponse(
total_products=stats_data["total_products"],
unique_brands=stats_data["unique_brands"],
unique_categories=stats_data["unique_categories"],
unique_marketplaces=stats_data["unique_marketplaces"],
unique_vendors=stats_data["unique_vendors"],
total_inventory_entries=stats_data["total_inventory_entries"],
total_inventory_quantity=stats_data["total_inventory_quantity"],
)
@admin_dashboard_router.get("/stats/marketplace", response_model=list[MarketplaceStatsResponse])
def get_marketplace_stats(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get statistics broken down by marketplace (Admin only)."""
marketplace_stats = stats_service.get_marketplace_breakdown_stats(db=db)
return [
MarketplaceStatsResponse(
marketplace=stat["marketplace"],
total_products=stat["total_products"],
unique_vendors=stat["unique_vendors"],
unique_brands=stat["unique_brands"],
)
for stat in marketplace_stats
]
@admin_dashboard_router.get("/stats/platform", response_model=PlatformStatsResponse)
def get_platform_statistics(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get comprehensive platform statistics (Admin only)."""
user_stats = stats_service.get_user_statistics(db)
vendor_stats = stats_service.get_vendor_statistics(db)
product_stats = stats_service.get_product_statistics(db)
order_stats = stats_service.get_order_statistics(db)
import_stats = stats_service.get_import_statistics(db)
return PlatformStatsResponse(
users=UserStatsResponse(**user_stats),
vendors=VendorStatsResponse(
total=vendor_stats.get("total", vendor_stats.get("total_vendors", 0)),
verified=vendor_stats.get(
"verified", vendor_stats.get("verified_vendors", 0)
),
pending=vendor_stats.get("pending", vendor_stats.get("pending_vendors", 0)),
inactive=vendor_stats.get(
"inactive", vendor_stats.get("inactive_vendors", 0)
),
),
products=ProductStatsResponse(**product_stats),
orders=OrderStatsBasicResponse(**order_stats),
imports=ImportStatsResponse(**import_stats),
)

View File

@@ -0,0 +1,716 @@
# app/modules/core/routes/api/admin_settings.py
"""
Platform settings management endpoints.
Provides endpoints for:
- Viewing all platform settings
- Creating/updating settings
- Managing configuration by category
- Email configuration status and testing
"""
import logging
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, EmailStr
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.config import settings as app_settings
from app.core.database import get_db
from app.exceptions import ResourceNotFoundException
from app.modules.tenancy.exceptions import ConfirmationRequiredException
from app.modules.monitoring.services.admin_audit_service import admin_audit_service
from app.modules.core.services.admin_settings_service import admin_settings_service
from models.schema.auth import UserContext
from models.schema.admin import (
AdminSettingCreate,
AdminSettingDefaultResponse,
AdminSettingListResponse,
AdminSettingResponse,
AdminSettingUpdate,
PublicDisplaySettingsResponse,
RowsPerPageResponse,
RowsPerPageUpdateResponse,
)
admin_settings_router = APIRouter(prefix="/settings")
logger = logging.getLogger(__name__)
@admin_settings_router.get("", response_model=AdminSettingListResponse)
def get_all_settings(
category: str | None = Query(None, description="Filter by category"),
is_public: bool | None = Query(None, description="Filter by public flag"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Get all platform settings.
Can be filtered by category (system, security, marketplace, notifications)
and by public flag (settings that can be exposed to frontend).
"""
settings = admin_settings_service.get_all_settings(db, category, is_public)
return AdminSettingListResponse(
settings=settings, total=len(settings), category=category
)
@admin_settings_router.get("/categories")
def get_setting_categories(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get list of all setting categories."""
# This could be enhanced to return counts per category
return {
"categories": [
"system",
"security",
"marketplace",
"notifications",
"integrations",
"payments",
]
}
@admin_settings_router.get("/{key}", response_model=AdminSettingResponse | AdminSettingDefaultResponse)
def get_setting(
key: str,
default: str | None = Query(None, description="Default value if setting not found"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> AdminSettingResponse | AdminSettingDefaultResponse:
"""Get specific setting by key.
If `default` is provided and the setting doesn't exist, returns a response
with the default value instead of 404.
"""
setting = admin_settings_service.get_setting_by_key(db, key)
if not setting:
if default is not None:
# Return default value without creating the setting
return AdminSettingDefaultResponse(key=key, value=default, exists=False)
raise ResourceNotFoundException(resource_type="Setting", identifier=key)
return AdminSettingResponse.model_validate(setting)
@admin_settings_router.post("", response_model=AdminSettingResponse)
def create_setting(
setting_data: AdminSettingCreate,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Create new platform setting.
Setting keys should be lowercase with underscores (e.g., max_vendors_allowed).
"""
result = admin_settings_service.create_setting(
db=db, setting_data=setting_data, admin_user_id=current_admin.id
)
# Log action
admin_audit_service.log_action(
db=db,
admin_user_id=current_admin.id,
action="create_setting",
target_type="setting",
target_id=setting_data.key,
details={
"category": setting_data.category,
"value_type": setting_data.value_type,
},
)
db.commit()
return result
@admin_settings_router.put("/{key}", response_model=AdminSettingResponse)
def update_setting(
key: str,
update_data: AdminSettingUpdate,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Update existing setting value."""
old_value = admin_settings_service.get_setting_value(db, key)
result = admin_settings_service.update_setting(
db=db, key=key, update_data=update_data, admin_user_id=current_admin.id
)
# Log action
admin_audit_service.log_action(
db=db,
admin_user_id=current_admin.id,
action="update_setting",
target_type="setting",
target_id=key,
details={"old_value": str(old_value), "new_value": update_data.value},
)
db.commit()
return result
@admin_settings_router.post("/upsert", response_model=AdminSettingResponse)
def upsert_setting(
setting_data: AdminSettingCreate,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Create or update setting (upsert).
If setting exists, updates its value. If not, creates new setting.
"""
result = admin_settings_service.upsert_setting(
db=db, setting_data=setting_data, admin_user_id=current_admin.id
)
# Log action
admin_audit_service.log_action(
db=db,
admin_user_id=current_admin.id,
action="upsert_setting",
target_type="setting",
target_id=setting_data.key,
details={"category": setting_data.category},
)
db.commit()
return result
# ============================================================================
# CONVENIENCE ENDPOINTS FOR COMMON SETTINGS
# ============================================================================
@admin_settings_router.get("/display/rows-per-page", response_model=RowsPerPageResponse)
def get_rows_per_page(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> RowsPerPageResponse:
"""Get the platform-wide rows per page setting."""
value = admin_settings_service.get_setting_value(db, "rows_per_page", default="20")
return RowsPerPageResponse(rows_per_page=int(value))
@admin_settings_router.put("/display/rows-per-page", response_model=RowsPerPageUpdateResponse)
def set_rows_per_page(
rows: int = Query(..., ge=10, le=100, description="Rows per page (10-100)"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> RowsPerPageUpdateResponse:
"""
Set the platform-wide rows per page setting.
Valid values: 10, 20, 50, 100
"""
valid_values = [10, 20, 50, 100]
if rows not in valid_values:
# Round to nearest valid value
rows = min(valid_values, key=lambda x: abs(x - rows))
setting_data = AdminSettingCreate(
key="rows_per_page",
value=str(rows),
value_type="integer",
category="display",
description="Default number of rows per page in admin tables",
is_public=True,
)
admin_settings_service.upsert_setting(
db=db, setting_data=setting_data, admin_user_id=current_admin.id
)
admin_audit_service.log_action(
db=db,
admin_user_id=current_admin.id,
action="update_setting",
target_type="setting",
target_id="rows_per_page",
details={"value": rows},
)
db.commit()
return RowsPerPageUpdateResponse(
rows_per_page=rows, message="Rows per page setting updated"
)
@admin_settings_router.get("/display/public", response_model=PublicDisplaySettingsResponse)
def get_public_display_settings(
db: Session = Depends(get_db),
) -> PublicDisplaySettingsResponse:
"""
Get public display settings (no auth required).
Returns settings that can be used by frontend without admin auth.
"""
rows_per_page = admin_settings_service.get_setting_value(
db, "rows_per_page", default="20"
)
return PublicDisplaySettingsResponse(rows_per_page=int(rows_per_page))
@admin_settings_router.delete("/{key}")
def delete_setting(
key: str,
confirm: bool = Query(False, description="Must be true to confirm deletion"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Delete platform setting.
Requires confirmation parameter.
WARNING: Deleting settings may affect platform functionality.
"""
if not confirm:
raise ConfirmationRequiredException(
operation="delete_setting",
message="Deletion requires confirmation parameter: confirm=true",
)
message = admin_settings_service.delete_setting(
db=db, key=key, admin_user_id=current_admin.id
)
# Log action
admin_audit_service.log_action(
db=db,
admin_user_id=current_admin.id,
action="delete_setting",
target_type="setting",
target_id=key,
details={},
)
db.commit()
return {"message": message}
# ============================================================================
# EMAIL CONFIGURATION ENDPOINTS
# ============================================================================
# Email setting keys stored in admin_settings table
EMAIL_SETTING_KEYS = {
"email_provider": "smtp",
"email_from_address": "",
"email_from_name": "",
"email_reply_to": "",
"smtp_host": "",
"smtp_port": "587",
"smtp_user": "",
"smtp_password": "",
"smtp_use_tls": "true",
"smtp_use_ssl": "false",
"sendgrid_api_key": "",
"mailgun_api_key": "",
"mailgun_domain": "",
"aws_access_key_id": "",
"aws_secret_access_key": "",
"aws_region": "eu-west-1",
"email_enabled": "true",
"email_debug": "false",
}
def get_email_setting(db: Session, key: str) -> str | None:
"""Get email setting from database, returns None if not set."""
setting = admin_settings_service.get_setting_by_key(db, key)
return setting.value if setting else None
def get_effective_email_config(db: Session) -> dict:
"""
Get effective email configuration.
Priority: Database settings > Environment variables
"""
config = {}
# Provider
db_provider = get_email_setting(db, "email_provider")
config["provider"] = db_provider if db_provider else app_settings.email_provider
# From settings
db_from_email = get_email_setting(db, "email_from_address")
config["from_email"] = db_from_email if db_from_email else app_settings.email_from_address
db_from_name = get_email_setting(db, "email_from_name")
config["from_name"] = db_from_name if db_from_name else app_settings.email_from_name
db_reply_to = get_email_setting(db, "email_reply_to")
config["reply_to"] = db_reply_to if db_reply_to else app_settings.email_reply_to
# SMTP settings
db_smtp_host = get_email_setting(db, "smtp_host")
config["smtp_host"] = db_smtp_host if db_smtp_host else app_settings.smtp_host
db_smtp_port = get_email_setting(db, "smtp_port")
config["smtp_port"] = int(db_smtp_port) if db_smtp_port else app_settings.smtp_port
db_smtp_user = get_email_setting(db, "smtp_user")
config["smtp_user"] = db_smtp_user if db_smtp_user else app_settings.smtp_user
db_smtp_password = get_email_setting(db, "smtp_password")
config["smtp_password"] = db_smtp_password if db_smtp_password else app_settings.smtp_password
db_smtp_use_tls = get_email_setting(db, "smtp_use_tls")
config["smtp_use_tls"] = db_smtp_use_tls.lower() in ("true", "1", "yes") if db_smtp_use_tls else app_settings.smtp_use_tls
db_smtp_use_ssl = get_email_setting(db, "smtp_use_ssl")
config["smtp_use_ssl"] = db_smtp_use_ssl.lower() in ("true", "1", "yes") if db_smtp_use_ssl else app_settings.smtp_use_ssl
# SendGrid
db_sendgrid_key = get_email_setting(db, "sendgrid_api_key")
config["sendgrid_api_key"] = db_sendgrid_key if db_sendgrid_key else app_settings.sendgrid_api_key
# Mailgun
db_mailgun_key = get_email_setting(db, "mailgun_api_key")
config["mailgun_api_key"] = db_mailgun_key if db_mailgun_key else app_settings.mailgun_api_key
db_mailgun_domain = get_email_setting(db, "mailgun_domain")
config["mailgun_domain"] = db_mailgun_domain if db_mailgun_domain else app_settings.mailgun_domain
# AWS SES
db_aws_key = get_email_setting(db, "aws_access_key_id")
config["aws_access_key_id"] = db_aws_key if db_aws_key else app_settings.aws_access_key_id
db_aws_secret = get_email_setting(db, "aws_secret_access_key")
config["aws_secret_access_key"] = db_aws_secret if db_aws_secret else app_settings.aws_secret_access_key
db_aws_region = get_email_setting(db, "aws_region")
config["aws_region"] = db_aws_region if db_aws_region else app_settings.aws_region
# Behavior
db_enabled = get_email_setting(db, "email_enabled")
config["enabled"] = db_enabled.lower() in ("true", "1", "yes") if db_enabled else app_settings.email_enabled
db_debug = get_email_setting(db, "email_debug")
config["debug"] = db_debug.lower() in ("true", "1", "yes") if db_debug else app_settings.email_debug
# Track source for each field (DB override or .env)
config["_sources"] = {}
for key in ["provider", "from_email", "from_name", "smtp_host", "smtp_port"]:
db_key = "email_provider" if key == "provider" else ("email_from_address" if key == "from_email" else ("email_from_name" if key == "from_name" else key))
config["_sources"][key] = "database" if get_email_setting(db, db_key) else "env"
return config
class EmailStatusResponse(BaseModel):
"""Platform email configuration status."""
provider: str
from_email: str
from_name: str
reply_to: str | None = None
smtp_host: str | None = None
smtp_port: int | None = None
smtp_user: str | None = None
mailgun_domain: str | None = None
aws_region: str | None = None
debug: bool
enabled: bool
is_configured: bool
has_db_overrides: bool = False
class EmailSettingsUpdate(BaseModel):
"""Update email settings."""
provider: str | None = None
from_email: EmailStr | None = None
from_name: str | None = None
reply_to: EmailStr | None = None
# SMTP
smtp_host: str | None = None
smtp_port: int | None = None
smtp_user: str | None = None
smtp_password: str | None = None
smtp_use_tls: bool | None = None
smtp_use_ssl: bool | None = None
# SendGrid
sendgrid_api_key: str | None = None
# Mailgun
mailgun_api_key: str | None = None
mailgun_domain: str | None = None
# AWS SES
aws_access_key_id: str | None = None
aws_secret_access_key: str | None = None
aws_region: str | None = None
# Behavior
enabled: bool | None = None
debug: bool | None = None
class TestEmailRequest(BaseModel):
"""Request body for test email."""
to_email: EmailStr
class TestEmailResponse(BaseModel):
"""Response for test email."""
success: bool
message: str
@admin_settings_router.get("/email/status", response_model=EmailStatusResponse)
def get_email_status(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> EmailStatusResponse:
"""
Get platform email configuration status.
Returns the effective email configuration (DB overrides > .env).
Sensitive values (passwords, API keys) are NOT exposed.
"""
config = get_effective_email_config(db)
provider = config["provider"].lower()
# Determine if email is configured based on provider
is_configured = False
if provider == "smtp":
is_configured = bool(config["smtp_host"] and config["smtp_host"] != "localhost")
elif provider == "sendgrid":
is_configured = bool(config["sendgrid_api_key"])
elif provider == "mailgun":
is_configured = bool(config["mailgun_api_key"] and config["mailgun_domain"])
elif provider == "ses":
is_configured = bool(config["aws_access_key_id"] and config["aws_secret_access_key"])
# Check if any DB overrides exist
has_db_overrides = any(v == "database" for v in config["_sources"].values())
return EmailStatusResponse(
provider=provider,
from_email=config["from_email"],
from_name=config["from_name"],
reply_to=config["reply_to"] or None,
smtp_host=config["smtp_host"] if provider == "smtp" else None,
smtp_port=config["smtp_port"] if provider == "smtp" else None,
smtp_user=config["smtp_user"] if provider == "smtp" else None,
mailgun_domain=config["mailgun_domain"] if provider == "mailgun" else None,
aws_region=config["aws_region"] if provider == "ses" else None,
debug=config["debug"],
enabled=config["enabled"],
is_configured=is_configured,
has_db_overrides=has_db_overrides,
)
@admin_settings_router.put("/email/settings")
def update_email_settings(
settings_update: EmailSettingsUpdate,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Update platform email settings.
Settings are stored in the database and override .env values.
Only non-null values are updated.
"""
from models.schema.admin import AdminSettingCreate
updated_keys = []
# Map request fields to database keys
field_mappings = {
"provider": ("email_provider", "string"),
"from_email": ("email_from_address", "string"),
"from_name": ("email_from_name", "string"),
"reply_to": ("email_reply_to", "string"),
"smtp_host": ("smtp_host", "string"),
"smtp_port": ("smtp_port", "integer"),
"smtp_user": ("smtp_user", "string"),
"smtp_password": ("smtp_password", "string"),
"smtp_use_tls": ("smtp_use_tls", "boolean"),
"smtp_use_ssl": ("smtp_use_ssl", "boolean"),
"sendgrid_api_key": ("sendgrid_api_key", "string"),
"mailgun_api_key": ("mailgun_api_key", "string"),
"mailgun_domain": ("mailgun_domain", "string"),
"aws_access_key_id": ("aws_access_key_id", "string"),
"aws_secret_access_key": ("aws_secret_access_key", "string"),
"aws_region": ("aws_region", "string"),
"enabled": ("email_enabled", "boolean"),
"debug": ("email_debug", "boolean"),
}
# Sensitive fields that should be marked as encrypted
sensitive_keys = {
"smtp_password", "sendgrid_api_key", "mailgun_api_key",
"aws_access_key_id", "aws_secret_access_key"
}
for field, (db_key, value_type) in field_mappings.items():
value = getattr(settings_update, field, None)
if value is not None:
# Convert value to string for storage
if value_type == "boolean":
str_value = "true" if value else "false"
elif value_type == "integer":
str_value = str(value)
else:
str_value = str(value)
# Create or update setting
setting_data = AdminSettingCreate(
key=db_key,
value=str_value,
value_type=value_type,
category="email",
description=f"Email setting: {field}",
is_encrypted=db_key in sensitive_keys,
is_public=False,
)
admin_settings_service.upsert_setting(db, setting_data, current_admin.id)
updated_keys.append(field)
# Log action
admin_audit_service.log_action(
db=db,
admin_user_id=current_admin.id,
action="update_email_settings",
target_type="email_settings",
target_id="platform",
details={"updated_keys": updated_keys},
)
db.commit()
logger.info(f"Email settings updated by admin {current_admin.id}: {updated_keys}")
return {
"success": True,
"message": f"Updated {len(updated_keys)} email setting(s)",
"updated_keys": updated_keys,
}
@admin_settings_router.delete("/email/settings")
def reset_email_settings(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Reset email settings to use .env values.
Deletes all email settings from the database, reverting to .env configuration.
"""
deleted_count = 0
for key in EMAIL_SETTING_KEYS:
setting = admin_settings_service.get_setting_by_key(db, key)
if setting:
# Use service method for deletion (API-002 compliance)
admin_settings_service.delete_setting(db, key, current_admin.id)
deleted_count += 1
# Log action
admin_audit_service.log_action(
db=db,
admin_user_id=current_admin.id,
action="reset_email_settings",
target_type="email_settings",
target_id="platform",
details={"deleted_count": deleted_count},
)
db.commit()
logger.info(f"Email settings reset by admin {current_admin.id}, deleted {deleted_count} settings")
return {
"success": True,
"message": f"Reset {deleted_count} email setting(s) to .env defaults",
}
@admin_settings_router.post("/email/test", response_model=TestEmailResponse)
def send_test_email(
request: TestEmailRequest,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> TestEmailResponse:
"""
Send a test email using the platform email configuration.
This tests the email provider configuration from environment variables.
"""
from app.modules.messaging.services.email_service import EmailService
try:
email_service = EmailService(db)
# Send test email using platform configuration
email_log = email_service.send_raw(
to_email=request.to_email,
to_name=None,
subject="Wizamart Platform - Test Email",
body_html="""
<html>
<body style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #6b46c1;">Test Email from Wizamart</h2>
<p>This is a test email to verify your platform email configuration.</p>
<p>If you received this email, your email settings are working correctly!</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 20px 0;">
<p style="color: #6b7280; font-size: 12px;">
Provider: {provider}<br>
From: {from_email}
</p>
</body>
</html>
""".format(
provider=app_settings.email_provider,
from_email=app_settings.email_from_address,
),
body_text=f"Test email from Wizamart platform.\n\nProvider: {app_settings.email_provider}\nFrom: {app_settings.email_from_address}",
is_platform_email=True,
)
# Check if email was actually sent (send_raw returns EmailLog, not boolean)
if email_log.status == "sent":
# Log action
admin_audit_service.log_action(
db=db,
admin_user_id=current_admin.id,
action="send_test_email",
target_type="email",
target_id=request.to_email,
details={"provider": app_settings.email_provider},
)
db.commit()
return TestEmailResponse(
success=True,
message=f"Test email sent to {request.to_email}",
)
else:
return TestEmailResponse(
success=False,
message=email_log.error_message or "Failed to send test email. Check server logs for details.",
)
except Exception as e:
logger.error(f"Failed to send test email: {e}")
return TestEmailResponse(
success=False,
message=f"Error sending test email: {str(e)}",
)

View File

@@ -0,0 +1,190 @@
# app/modules/core/routes/api/public.py
"""
Public language API endpoints.
Handles:
- Setting language preference via cookie
- Getting current language info
- Listing available languages
All endpoints are public (no authentication required).
"""
import logging
from fastapi import APIRouter, Request, Response
from pydantic import BaseModel, Field
from app.utils.i18n import (
DEFAULT_LANGUAGE,
LANGUAGE_FLAGS,
LANGUAGE_NAMES,
LANGUAGE_NAMES_EN,
SUPPORTED_LANGUAGES,
get_language_info,
)
from middleware.language import LANGUAGE_COOKIE_NAME, set_language_cookie
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/language")
# =============================================================================
# Schemas
# =============================================================================
class SetLanguageRequest(BaseModel):
"""Request body for setting language preference."""
language: str = Field(
...,
description="Language code (en, fr, de, lb)",
min_length=2,
max_length=5,
)
class SetLanguageResponse(BaseModel):
"""Response after setting language preference."""
success: bool
language: str
message: str
class LanguageInfo(BaseModel):
"""Information about a single language."""
code: str
name: str
name_en: str
flag: str
class LanguageListResponse(BaseModel):
"""Response listing all available languages."""
languages: list[LanguageInfo]
current: str
default: str
class CurrentLanguageResponse(BaseModel):
"""Response with current language information."""
code: str
name: str
name_en: str
flag: str
source: str # Where the language was determined from (cookie, browser, default)
# =============================================================================
# Endpoints
# =============================================================================
@router.post("/set", response_model=SetLanguageResponse) # public
async def set_language(
request: Request,
response: Response,
body: SetLanguageRequest,
) -> SetLanguageResponse:
"""
Set the user's language preference.
This sets a cookie that will be used for subsequent requests.
The page should be reloaded after calling this endpoint.
"""
language = body.language.lower()
if language not in SUPPORTED_LANGUAGES:
return SetLanguageResponse(
success=False,
language=language,
message=f"Unsupported language: {language}. Supported: {', '.join(SUPPORTED_LANGUAGES)}",
)
# Set language cookie
set_language_cookie(response, language)
logger.info(f"Language preference set to: {language}")
return SetLanguageResponse(
success=True,
language=language,
message=f"Language set to {LANGUAGE_NAMES.get(language, language)}",
)
@router.get("/current", response_model=CurrentLanguageResponse) # public
async def get_current_language(request: Request) -> CurrentLanguageResponse:
"""
Get the current language for this request.
Returns information about the detected language and where it came from.
"""
# Get language from request state (set by middleware)
language = getattr(request.state, "language", DEFAULT_LANGUAGE)
language_info = getattr(request.state, "language_info", {})
# Determine source
source = "default"
if language_info.get("cookie"):
source = "cookie"
elif language_info.get("browser"):
source = "browser"
info = get_language_info(language)
return CurrentLanguageResponse(
code=info["code"],
name=info["name"],
name_en=info["name_en"],
flag=info["flag"],
source=source,
)
@router.get("/list", response_model=LanguageListResponse) # public
async def list_languages(request: Request) -> LanguageListResponse:
"""
List all available languages.
Returns all supported languages with their display names.
"""
current = getattr(request.state, "language", DEFAULT_LANGUAGE)
languages = [
LanguageInfo(
code=code,
name=LANGUAGE_NAMES.get(code, code),
name_en=LANGUAGE_NAMES_EN.get(code, code),
flag=LANGUAGE_FLAGS.get(code, ""),
)
for code in SUPPORTED_LANGUAGES
]
return LanguageListResponse(
languages=languages,
current=current,
default=DEFAULT_LANGUAGE,
)
@router.delete("/clear") # public
async def clear_language(response: Response) -> SetLanguageResponse:
"""
Clear the language preference cookie.
After clearing, the language will be determined from browser settings or defaults.
"""
response.delete_cookie(key=LANGUAGE_COOKIE_NAME)
return SetLanguageResponse(
success=True,
language=DEFAULT_LANGUAGE,
message="Language preference cleared. Using browser/default language.",
)

View File

@@ -13,9 +13,9 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.exceptions import VendorNotActiveException
from app.services.stats_service import stats_service
from app.services.vendor_service import vendor_service
from app.modules.tenancy.exceptions import VendorNotActiveException
from app.modules.analytics.services.stats_service import stats_service
from app.modules.tenancy.services.vendor_service import vendor_service
from models.schema.auth import UserContext
from app.modules.analytics.schemas import (
VendorCustomerStats,

View File

@@ -14,8 +14,8 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.services.platform_settings_service import platform_settings_service
from app.services.vendor_service import vendor_service
from app.modules.core.services.platform_settings_service import platform_settings_service
from app.modules.tenancy.services.vendor_service import vendor_service
from models.schema.auth import UserContext
vendor_settings_router = APIRouter(prefix="/settings")

View File

@@ -0,0 +1,2 @@
# app/modules/core/routes/pages/__init__.py
"""Core module page routes."""

View File

@@ -0,0 +1,159 @@
# app/modules/core/routes/pages/admin.py
"""
Core Admin Page Routes (HTML rendering).
Admin pages for core platform functionality:
- Login, logout, authentication
- Dashboard
- Settings
- Feature management
"""
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_optional, get_db, require_menu_access
from app.modules.core.utils.page_context import get_admin_context
from app.templates_config import templates
from models.database.admin_menu_config import FrontendType
from models.database.user import User
router = APIRouter()
# ============================================================================
# PUBLIC ROUTES (No Authentication Required)
# ============================================================================
@router.get("/", response_class=RedirectResponse, include_in_schema=False)
async def admin_root(
current_user: User | None = Depends(get_current_admin_optional),
):
"""
Redirect /admin/ based on authentication status.
- Authenticated admin users -> /admin/dashboard
- Unauthenticated users -> /admin/login
"""
if current_user:
return RedirectResponse(url="/admin/dashboard", status_code=302)
return RedirectResponse(url="/admin/login", status_code=302)
@router.get("/login", response_class=HTMLResponse, include_in_schema=False)
async def admin_login_page(
request: Request,
current_user: User | None = Depends(get_current_admin_optional),
):
"""
Render admin login page.
If user is already authenticated as admin, redirect to dashboard.
Otherwise, show login form.
"""
if current_user:
return RedirectResponse(url="/admin/dashboard", status_code=302)
return templates.TemplateResponse("tenancy/admin/login.html", {"request": request})
@router.get("/select-platform", response_class=HTMLResponse, include_in_schema=False)
async def admin_select_platform_page(
request: Request,
current_user: User | None = Depends(get_current_admin_optional),
):
"""
Render platform selection page for platform admins.
Platform admins with access to multiple platforms must select
which platform they want to manage before accessing the dashboard.
Super admins are redirected to dashboard (they have global access).
"""
if not current_user:
return RedirectResponse(url="/admin/login", status_code=302)
if current_user.is_super_admin:
return RedirectResponse(url="/admin/dashboard", status_code=302)
return templates.TemplateResponse(
"tenancy/admin/select-platform.html",
{"request": request, "user": current_user},
)
# ============================================================================
# AUTHENTICATED ROUTES (Admin Only)
# ============================================================================
@router.get("/dashboard", response_class=HTMLResponse, include_in_schema=False)
async def admin_dashboard_page(
request: Request,
current_user: User = Depends(require_menu_access("dashboard", FrontendType.ADMIN)),
db: Session = Depends(get_db),
):
"""
Render admin dashboard page.
Shows platform statistics and recent activity.
"""
return templates.TemplateResponse(
"core/admin/dashboard.html",
get_admin_context(request, current_user),
)
@router.get("/settings", response_class=HTMLResponse, include_in_schema=False)
async def admin_settings_page(
request: Request,
current_user: User = Depends(require_menu_access("settings", FrontendType.ADMIN)),
db: Session = Depends(get_db),
):
"""
Render admin settings page.
Platform configuration and preferences.
"""
return templates.TemplateResponse(
"core/admin/settings.html",
get_admin_context(request, current_user),
)
@router.get("/my-menu", response_class=HTMLResponse, include_in_schema=False)
async def admin_my_menu_config(
request: Request,
current_user: User = Depends(require_menu_access("my-menu", FrontendType.ADMIN)),
db: Session = Depends(get_db),
):
"""
Render personal menu configuration page for super admins.
Allows super admins to customize their own sidebar menu.
"""
# Only super admins can configure their own menu
if not current_user.is_super_admin:
return RedirectResponse(url="/admin/settings", status_code=302)
return templates.TemplateResponse(
"core/admin/my-menu-config.html",
get_admin_context(request, current_user),
)
@router.get("/features", response_class=HTMLResponse, include_in_schema=False)
async def admin_features_page(
request: Request,
current_user: User = Depends(
require_menu_access("subscription-tiers", FrontendType.ADMIN)
),
db: Session = Depends(get_db),
):
"""
Render feature management page.
Shows all features with tier assignments and allows editing.
"""
return templates.TemplateResponse(
"billing/admin/features.html",
get_admin_context(request, current_user),
)

View File

@@ -0,0 +1,69 @@
# app/modules/core/routes/pages/vendor.py
"""
Core Vendor Page Routes (HTML rendering).
Vendor pages for core functionality:
- Media library
- Notifications
"""
from fastapi import APIRouter, Depends, Path, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_from_cookie_or_header, get_db
from app.modules.core.utils.page_context import get_vendor_context
from app.templates_config import templates
from models.database.user import User
router = APIRouter()
# ============================================================================
# MEDIA LIBRARY
# ============================================================================
@router.get(
"/{vendor_code}/media", response_class=HTMLResponse, include_in_schema=False
)
async def vendor_media_page(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render media library page.
JavaScript loads media files via API.
"""
return templates.TemplateResponse(
"cms/vendor/media.html",
get_vendor_context(request, db, current_user, vendor_code),
)
# ============================================================================
# NOTIFICATIONS
# ============================================================================
@router.get(
"/{vendor_code}/notifications",
response_class=HTMLResponse,
include_in_schema=False,
)
async def vendor_notifications_page(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render notifications center page.
JavaScript loads notifications via API.
"""
return templates.TemplateResponse(
"messaging/vendor/notifications.html",
get_vendor_context(request, db, current_user, vendor_code),
)

View File

@@ -0,0 +1,56 @@
# app/modules/core/services/__init__.py
"""
Core module services.
Provides foundational services used across the platform:
- auth_service: Authentication and authorization
- menu_service: Menu visibility and configuration
- image_service: Image upload and management
- storage_service: Storage abstraction (local/R2)
- admin_settings_service: Platform-wide admin settings
- platform_settings_service: Platform settings with resolution chain
"""
from app.modules.core.services.admin_settings_service import (
AdminSettingsService,
admin_settings_service,
)
from app.modules.core.services.auth_service import AuthService, auth_service
from app.modules.core.services.image_service import ImageService, image_service
from app.modules.core.services.menu_service import MenuItemConfig, MenuService, menu_service
from app.modules.core.services.platform_settings_service import (
PlatformSettingsService,
platform_settings_service,
)
from app.modules.core.services.storage_service import (
LocalStorageBackend,
R2StorageBackend,
StorageBackend,
get_storage_backend,
reset_storage_backend,
)
__all__ = [
# Auth
"AuthService",
"auth_service",
# Menu
"MenuService",
"MenuItemConfig",
"menu_service",
# Image
"ImageService",
"image_service",
# Storage
"StorageBackend",
"LocalStorageBackend",
"R2StorageBackend",
"get_storage_backend",
"reset_storage_backend",
# Admin settings
"AdminSettingsService",
"admin_settings_service",
# Platform settings
"PlatformSettingsService",
"platform_settings_service",
]

View File

@@ -0,0 +1,289 @@
# app/modules/core/services/admin_settings_service.py
"""
Admin settings service for platform-wide configuration.
This module provides functions for:
- Managing platform settings
- Getting/setting configuration values
- Encrypting sensitive settings
"""
import json
import logging
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.exceptions import (
ResourceNotFoundException,
ValidationException,
)
from app.modules.tenancy.exceptions import AdminOperationException
from models.database.admin import AdminSetting
from models.schema.admin import (
AdminSettingCreate,
AdminSettingResponse,
AdminSettingUpdate,
)
logger = logging.getLogger(__name__)
class AdminSettingsService:
"""Service for managing platform-wide settings."""
def get_setting_by_key(self, db: Session, key: str) -> AdminSetting | None:
"""Get setting by key."""
try:
return (
db.query(AdminSetting)
.filter(func.lower(AdminSetting.key) == key.lower())
.first()
)
except Exception as e:
logger.error(f"Failed to get setting {key}: {str(e)}")
return None
def get_setting_value(self, db: Session, key: str, default: Any = None) -> Any:
"""
Get setting value with type conversion.
Args:
key: Setting key
default: Default value if setting doesn't exist
Returns:
Typed setting value
"""
setting = self.get_setting_by_key(db, key)
if not setting:
return default
# Convert value based on type
try:
if setting.value_type == "integer":
return int(setting.value)
if setting.value_type == "float":
return float(setting.value)
if setting.value_type == "boolean":
return setting.value.lower() in ("true", "1", "yes")
if setting.value_type == "json":
return json.loads(setting.value)
return setting.value
except Exception as e:
logger.error(f"Failed to convert setting {key} value: {str(e)}")
return default
def get_all_settings(
self,
db: Session,
category: str | None = None,
is_public: bool | None = None,
) -> list[AdminSettingResponse]:
"""Get all settings with optional filtering."""
try:
query = db.query(AdminSetting)
if category:
query = query.filter(AdminSetting.category == category)
if is_public is not None:
query = query.filter(AdminSetting.is_public == is_public)
settings = query.order_by(AdminSetting.category, AdminSetting.key).all()
return [
AdminSettingResponse.model_validate(setting) for setting in settings
]
except Exception as e:
logger.error(f"Failed to get settings: {str(e)}")
raise AdminOperationException(
operation="get_all_settings", reason="Database query failed"
)
def get_settings_by_category(self, db: Session, category: str) -> dict[str, Any]:
"""
Get all settings in a category as a dictionary.
Returns:
Dictionary of key-value pairs
"""
settings = self.get_all_settings(db, category=category)
result = {}
for setting in settings:
# Convert value based on type
if setting.value_type == "integer":
result[setting.key] = int(setting.value)
elif setting.value_type == "float":
result[setting.key] = float(setting.value)
elif setting.value_type == "boolean":
result[setting.key] = setting.value.lower() in ("true", "1", "yes")
elif setting.value_type == "json":
result[setting.key] = json.loads(setting.value)
else:
result[setting.key] = setting.value
return result
def create_setting(
self, db: Session, setting_data: AdminSettingCreate, admin_user_id: int
) -> AdminSettingResponse:
"""Create new setting."""
try:
# Check if setting already exists
existing = self.get_setting_by_key(db, setting_data.key)
if existing:
raise ValidationException(
f"Setting with key '{setting_data.key}' already exists"
)
# Validate value based on type
self._validate_setting_value(setting_data.value, setting_data.value_type)
# TODO: Encrypt value if is_encrypted=True
value_to_store = setting_data.value
if setting_data.is_encrypted:
# value_to_store = self._encrypt_value(setting_data.value)
pass
setting = AdminSetting(
key=setting_data.key.lower(),
value=value_to_store,
value_type=setting_data.value_type,
category=setting_data.category,
description=setting_data.description,
is_encrypted=setting_data.is_encrypted,
is_public=setting_data.is_public,
last_modified_by_user_id=admin_user_id,
)
db.add(setting)
db.flush()
db.refresh(setting)
logger.info(f"Setting '{setting.key}' created by admin {admin_user_id}")
return AdminSettingResponse.model_validate(setting)
except ValidationException:
raise
except Exception as e:
logger.error(f"Failed to create setting: {str(e)}")
raise AdminOperationException(
operation="create_setting", reason="Database operation failed"
)
def update_setting(
self, db: Session, key: str, update_data: AdminSettingUpdate, admin_user_id: int
) -> AdminSettingResponse:
"""Update existing setting."""
setting = self.get_setting_by_key(db, key)
if not setting:
raise ResourceNotFoundException(resource_type="setting", identifier=key)
try:
# Validate new value
self._validate_setting_value(update_data.value, setting.value_type)
# TODO: Encrypt value if needed
value_to_store = update_data.value
if setting.is_encrypted:
# value_to_store = self._encrypt_value(update_data.value)
pass
setting.value = value_to_store
if update_data.description is not None:
setting.description = update_data.description
setting.last_modified_by_user_id = admin_user_id
setting.updated_at = datetime.now(UTC)
db.flush()
db.refresh(setting)
logger.info(f"Setting '{setting.key}' updated by admin {admin_user_id}")
return AdminSettingResponse.model_validate(setting)
except ValidationException:
raise
except Exception as e:
logger.error(f"Failed to update setting {key}: {str(e)}")
raise AdminOperationException(
operation="update_setting", reason="Database operation failed"
)
def upsert_setting(
self, db: Session, setting_data: AdminSettingCreate, admin_user_id: int
) -> AdminSettingResponse:
"""Create or update setting (upsert)."""
existing = self.get_setting_by_key(db, setting_data.key)
if existing:
update_data = AdminSettingUpdate(
value=setting_data.value, description=setting_data.description
)
return self.update_setting(db, setting_data.key, update_data, admin_user_id)
return self.create_setting(db, setting_data, admin_user_id)
def delete_setting(self, db: Session, key: str, admin_user_id: int) -> str:
"""Delete setting."""
setting = self.get_setting_by_key(db, key)
if not setting:
raise ResourceNotFoundException(resource_type="setting", identifier=key)
try:
db.delete(setting)
logger.warning(f"Setting '{key}' deleted by admin {admin_user_id}")
return f"Setting '{key}' successfully deleted"
except Exception as e:
logger.error(f"Failed to delete setting {key}: {str(e)}")
raise AdminOperationException(
operation="delete_setting", reason="Database operation failed"
)
# ============================================================================
# HELPER METHODS
# ============================================================================
def _validate_setting_value(self, value: str, value_type: str):
"""Validate setting value matches declared type."""
try:
if value_type == "integer":
int(value)
elif value_type == "float":
float(value)
elif value_type == "boolean":
if value.lower() not in ("true", "false", "1", "0", "yes", "no"):
raise ValueError("Invalid boolean value")
elif value_type == "json":
json.loads(value)
except Exception as e:
raise ValidationException(
f"Value '{value}' is not valid for type '{value_type}': {str(e)}"
)
def _encrypt_value(self, value: str) -> str:
"""Encrypt sensitive setting value."""
# TODO: Implement encryption using Fernet or similar
# from cryptography.fernet import Fernet
# return encrypted_value
return value
def _decrypt_value(self, encrypted_value: str) -> str:
"""Decrypt sensitive setting value."""
# TODO: Implement decryption
return encrypted_value
# Create service instance
admin_settings_service = AdminSettingsService()

View File

@@ -0,0 +1,161 @@
# app/modules/core/services/auth_service.py
"""
Authentication service for user login and vendor access control.
This module provides:
- User authentication and JWT token generation
- Vendor access verification
- Password hashing utilities
Note: Customer registration is handled by CustomerService.
User (admin/vendor team) creation is handled by their respective services.
"""
import logging
from datetime import UTC, datetime
from typing import Any
from sqlalchemy.orm import Session
from app.modules.tenancy.exceptions import InvalidCredentialsException, UserNotActiveException
from middleware.auth import AuthManager
from models.database.user import User
from models.database.vendor import Vendor, VendorUser
from models.schema.auth import UserLogin
logger = logging.getLogger(__name__)
class AuthService:
"""Service class for authentication operations."""
def __init__(self):
"""Initialize with AuthManager instance."""
self.auth_manager = AuthManager()
def login_user(self, db: Session, user_credentials: UserLogin) -> dict[str, Any]:
"""
Login user and return JWT token with user data.
Args:
db: Database session
user_credentials: User login credentials
Returns:
Dictionary containing access token data and user object
Raises:
InvalidCredentialsException: If authentication fails
UserNotActiveException: If user account is not active
"""
user = self.auth_manager.authenticate_user(
db, user_credentials.email_or_username, user_credentials.password
)
if not user:
raise InvalidCredentialsException("Incorrect username or password")
if not user.is_active:
raise UserNotActiveException("User account is not active")
# Update last_login timestamp
user.last_login = datetime.now(UTC)
db.commit() # noqa: SVC-006 - Login must persist last_login timestamp
token_data = self.auth_manager.create_access_token(user)
logger.info(f"User logged in: {user.username}")
return {"token_data": token_data, "user": user}
def hash_password(self, password: str) -> str:
"""
Hash a password.
Args:
password: Plain text password
Returns:
Hashed password string
"""
return self.auth_manager.hash_password(password)
def get_vendor_by_code(self, db: Session, vendor_code: str) -> Vendor | None:
"""
Get active vendor by vendor code.
Args:
db: Database session
vendor_code: Vendor code to look up
Returns:
Vendor if found and active, None otherwise
"""
return (
db.query(Vendor)
.filter(Vendor.vendor_code == vendor_code.upper(), Vendor.is_active == True)
.first()
)
def get_user_vendor_role(
self, db: Session, user: User, vendor: Vendor
) -> tuple[bool, str | None]:
"""
Check if user has access to vendor and return their role.
Args:
db: Database session
user: User to check
vendor: Vendor to check access for
Returns:
Tuple of (has_access: bool, role_name: str | None)
"""
# Check if user is vendor owner (via company ownership)
if vendor.company and vendor.company.owner_user_id == user.id:
return True, "Owner"
# Check if user is team member
vendor_user = (
db.query(VendorUser)
.filter(
VendorUser.user_id == user.id,
VendorUser.vendor_id == vendor.id,
VendorUser.is_active == True,
)
.first()
)
if vendor_user:
return True, vendor_user.role.name
return False, None
def find_user_vendor(self, user: User) -> tuple[Vendor | None, str | None]:
"""
Find which vendor a user belongs to when no vendor context is provided.
Checks owned companies first, then vendor memberships.
Args:
user: User to find vendor for
Returns:
Tuple of (vendor: Vendor | None, role: str | None)
"""
# Check owned vendors first (via company ownership)
for company in user.owned_companies:
if company.vendors:
return company.vendors[0], "Owner"
# Check vendor memberships
if user.vendor_memberships:
active_membership = next(
(vm for vm in user.vendor_memberships if vm.is_active), None
)
if active_membership:
return active_membership.vendor, active_membership.role.name
return None, None
# Create service instance
auth_service = AuthService()

View File

@@ -0,0 +1,307 @@
# app/modules/core/services/image_service.py
"""
Image upload and management service.
Provides:
- Image upload with automatic optimization
- WebP conversion
- Multiple size variant generation
- Sharded directory structure for performance
"""
import hashlib
import logging
import os
from datetime import datetime
from io import BytesIO
from pathlib import Path
from PIL import Image
from app.exceptions import ValidationException
logger = logging.getLogger(__name__)
# Maximum upload size (10MB)
MAX_UPLOAD_SIZE = 10 * 1024 * 1024
class ImageService:
"""Service for image upload and management."""
# Supported image formats
ALLOWED_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "webp"}
ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
# Size variants to generate
SIZES = {
"original": None, # No max dimension, just optimize
"800": 800, # Medium size for product cards
"200": 200, # Thumbnail for grids
}
# Quality settings
QUALITY = 85
MAX_DIMENSION = 2000 # Max dimension for original
def __init__(self, upload_dir: str = "static/uploads"):
"""Initialize image service.
Args:
upload_dir: Base directory for uploads (relative to project root)
"""
self.upload_dir = Path(upload_dir)
self.products_dir = self.upload_dir / "products"
# Ensure directories exist
self.products_dir.mkdir(parents=True, exist_ok=True)
def upload_product_image(
self,
file_content: bytes,
filename: str,
vendor_id: int,
product_id: int | None = None,
content_type: str | None = None,
) -> dict:
"""Upload and process a product image.
Args:
file_content: Raw file bytes
filename: Original filename
vendor_id: Vendor ID for path generation
product_id: Optional product ID
content_type: MIME type of the uploaded file
Returns:
Dict with image info and URLs
Raises:
ValidationException: If file is too large or invalid type
"""
# Validate file size
if len(file_content) > MAX_UPLOAD_SIZE:
raise ValidationException(
f"File too large. Maximum size: {MAX_UPLOAD_SIZE // (1024*1024)}MB"
)
# Validate content type
if not content_type or not content_type.startswith("image/"):
raise ValidationException("Invalid file type. Only images are allowed.")
# Validate file extension
ext = self._get_extension(filename)
if ext not in self.ALLOWED_EXTENSIONS:
raise ValidationException(
f"Invalid file type: {ext}. Allowed: {', '.join(self.ALLOWED_EXTENSIONS)}"
)
# Generate unique hash for this image
image_hash = self._generate_hash(vendor_id, product_id, filename)
# Determine sharded directory path
shard_path = self._get_shard_path(image_hash)
full_dir = self.products_dir / shard_path
full_dir.mkdir(parents=True, exist_ok=True)
# Load and process image
try:
img = Image.open(BytesIO(file_content))
# Convert to RGB if necessary (for PNG with alpha)
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
# Get original dimensions
original_width, original_height = img.size
# Process and save variants
urls = {}
total_size = 0
for size_name, max_dim in self.SIZES.items():
processed_img = self._resize_image(img.copy(), max_dim)
file_path = full_dir / f"{image_hash}_{size_name}.webp"
# Save as WebP
processed_img.save(file_path, "WEBP", quality=self.QUALITY)
# Track size
file_size = file_path.stat().st_size
total_size += file_size
# Generate URL path (relative to static)
url_path = f"/static/uploads/products/{shard_path}/{image_hash}_{size_name}.webp"
urls[size_name] = url_path
logger.debug(f"Saved {size_name}: {file_path} ({file_size} bytes)")
logger.info(
f"Uploaded image {image_hash} for vendor {vendor_id}: "
f"{len(urls)} variants, {total_size} bytes total"
)
return {
"id": image_hash,
"urls": urls,
"size_bytes": total_size,
"dimensions": {
"width": original_width,
"height": original_height,
},
"path": str(shard_path),
}
except Exception as e:
logger.error(f"Failed to process image: {e}")
raise ValueError(f"Failed to process image: {e}")
def delete_product_image(self, image_hash: str) -> bool:
"""Delete all variants of a product image.
Args:
image_hash: The image hash/ID
Returns:
True if deleted, False if not found
"""
shard_path = self._get_shard_path(image_hash)
full_dir = self.products_dir / shard_path
if not full_dir.exists():
return False
deleted = False
for size_name in self.SIZES:
file_path = full_dir / f"{image_hash}_{size_name}.webp"
if file_path.exists():
file_path.unlink()
deleted = True
logger.debug(f"Deleted: {file_path}")
# Clean up empty directories
self._cleanup_empty_dirs(full_dir)
if deleted:
logger.info(f"Deleted image {image_hash}")
return deleted
def get_storage_stats(self) -> dict:
"""Get storage statistics.
Returns:
Dict with storage metrics
"""
total_files = 0
total_size = 0
max_files_per_dir = 0
dir_count = 0
for root, dirs, files in os.walk(self.products_dir):
webp_files = [f for f in files if f.endswith(".webp")]
file_count = len(webp_files)
total_files += file_count
if file_count > 0:
dir_count += 1
max_files_per_dir = max(max_files_per_dir, file_count)
for f in webp_files:
file_path = Path(root) / f
total_size += file_path.stat().st_size
# Calculate average files per directory
avg_files_per_dir = total_files / dir_count if dir_count > 0 else 0
return {
"total_files": total_files,
"total_size_bytes": total_size,
"total_size_mb": round(total_size / (1024 * 1024), 2),
"total_size_gb": round(total_size / (1024 * 1024 * 1024), 3),
"directory_count": dir_count,
"max_files_per_dir": max_files_per_dir,
"avg_files_per_dir": round(avg_files_per_dir, 1),
"products_estimated": total_files // 3, # 3 variants per image
}
def _generate_hash(
self, vendor_id: int, product_id: int | None, filename: str
) -> str:
"""Generate unique hash for image.
Args:
vendor_id: Vendor ID
product_id: Product ID (optional)
filename: Original filename
Returns:
8-character hex hash
"""
timestamp = datetime.utcnow().isoformat()
content = f"{vendor_id}:{product_id}:{timestamp}:{filename}"
return hashlib.md5(content.encode()).hexdigest()[:8] # noqa: SEC-041
def _get_shard_path(self, image_hash: str) -> str:
"""Get sharded directory path from hash.
Uses first 4 characters to create 2-level directory structure.
This creates 256 possible directories at each level.
Args:
image_hash: 8-character hash
Returns:
Path like "0a/1b"
"""
return f"{image_hash[:2]}/{image_hash[2:4]}"
def _get_extension(self, filename: str) -> str:
"""Get lowercase file extension."""
return filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
def _resize_image(self, img: Image.Image, max_dimension: int | None) -> Image.Image:
"""Resize image while maintaining aspect ratio.
Args:
img: PIL Image
max_dimension: Maximum width or height (None = use MAX_DIMENSION)
Returns:
Resized PIL Image
"""
if max_dimension is None:
max_dimension = self.MAX_DIMENSION
width, height = img.size
# Only resize if larger than max
if width <= max_dimension and height <= max_dimension:
return img
# Calculate new dimensions maintaining aspect ratio
if width > height:
new_width = max_dimension
new_height = int(height * (max_dimension / width))
else:
new_height = max_dimension
new_width = int(width * (max_dimension / height))
return img.resize((new_width, new_height), Image.Resampling.LANCZOS)
def _cleanup_empty_dirs(self, dir_path: Path):
"""Remove empty directories up the tree."""
try:
# Try to remove the directory and its parents if empty
while dir_path != self.products_dir:
if dir_path.exists() and not any(dir_path.iterdir()):
dir_path.rmdir()
dir_path = dir_path.parent
else:
break
except OSError:
pass # Directory not empty or other error
# Create service instance
image_service = ImageService()

View File

@@ -0,0 +1,811 @@
# app/modules/core/services/menu_service.py
"""
Menu service for platform-specific menu configuration.
Provides:
- Menu visibility checking based on platform/user configuration
- Module-based filtering (menu items only shown if module is enabled)
- Filtered menu rendering for frontends
- Menu configuration management (super admin only)
- Mandatory item enforcement
Menu Resolution Order:
1. Module enablement: Is the module providing this item enabled?
2. Visibility config: Is this item explicitly shown/hidden?
3. Mandatory status: Is this item mandatory (always visible)?
Usage:
from app.modules.core.services import menu_service
# Check if menu item is accessible
if menu_service.can_access_menu_item(db, FrontendType.ADMIN, "inventory", platform_id=1):
...
# Get filtered menu for rendering
menu = menu_service.get_menu_for_rendering(db, FrontendType.ADMIN, platform_id=1)
# Update menu visibility (super admin)
menu_service.update_menu_visibility(db, FrontendType.ADMIN, "inventory", False, platform_id=1)
"""
import logging
from copy import deepcopy
from dataclasses import dataclass
from sqlalchemy.orm import Session
from app.config.menu_registry import (
ADMIN_MENU_REGISTRY,
VENDOR_MENU_REGISTRY,
get_all_menu_item_ids,
get_menu_item,
is_super_admin_only_item,
)
from app.modules.service import module_service
from models.database.admin_menu_config import (
AdminMenuConfig,
FrontendType,
MANDATORY_MENU_ITEMS,
)
logger = logging.getLogger(__name__)
@dataclass
class MenuItemConfig:
"""Menu item configuration for admin UI."""
id: str
label: str
icon: str
url: str
section_id: str
section_label: str | None
is_visible: bool
is_mandatory: bool
is_super_admin_only: bool
is_module_enabled: bool = True # Whether the module providing this item is enabled
module_code: str | None = None # Module that provides this item
class MenuService:
"""
Service for menu visibility configuration and rendering.
Menu visibility is an opt-in model:
- All items are hidden by default (except mandatory)
- Database stores explicitly shown items (is_visible=True)
- Mandatory items are always visible and cannot be hidden
"""
# =========================================================================
# Menu Access Checking
# =========================================================================
def can_access_menu_item(
self,
db: Session,
frontend_type: FrontendType,
menu_item_id: str,
platform_id: int | None = None,
user_id: int | None = None,
) -> bool:
"""
Check if a menu item is accessible for a given scope.
Checks in order:
1. Menu item exists in registry
2. Module providing this item is enabled (if platform_id given)
3. Mandatory status
4. Visibility configuration
Args:
db: Database session
frontend_type: Which frontend (admin or vendor)
menu_item_id: Menu item identifier
platform_id: Platform ID (for platform admins and vendors)
user_id: User ID (for super admins only)
Returns:
True if menu item is visible/accessible
"""
# Validate menu item exists in registry
all_items = get_all_menu_item_ids(frontend_type)
if menu_item_id not in all_items:
logger.warning(f"Unknown menu item: {menu_item_id} for {frontend_type.value}")
return False
# Check module enablement if platform is specified
if platform_id:
if not module_service.is_menu_item_module_enabled(
db, platform_id, menu_item_id, frontend_type
):
return False
# Mandatory items are always accessible (if module is enabled)
if menu_item_id in MANDATORY_MENU_ITEMS.get(frontend_type, set()):
return True
# No scope specified - show all by default (fallback for unconfigured)
if not platform_id and not user_id:
return True
# Get visibility from database (opt-in: must be explicitly shown)
shown_items = self._get_shown_items(db, frontend_type, platform_id, user_id)
# If no configuration exists, show all items (first-time setup)
if shown_items is None:
return True
return menu_item_id in shown_items
def get_visible_menu_items(
self,
db: Session,
frontend_type: FrontendType,
platform_id: int | None = None,
user_id: int | None = None,
) -> set[str]:
"""
Get set of visible menu item IDs for a scope.
Filters by:
1. Module enablement (if platform_id given)
2. Visibility configuration
3. Mandatory status
Args:
db: Database session
frontend_type: Which frontend (admin or vendor)
platform_id: Platform ID (for platform admins and vendors)
user_id: User ID (for super admins only)
Returns:
Set of visible menu item IDs
"""
all_items = get_all_menu_item_ids(frontend_type)
mandatory_items = MANDATORY_MENU_ITEMS.get(frontend_type, set())
# Filter by module enablement if platform is specified
if platform_id:
module_available_items = module_service.get_module_menu_items(
db, platform_id, frontend_type
)
# Only keep items from enabled modules (or items not associated with any module)
all_items = module_service.filter_menu_items_by_modules(
db, platform_id, all_items, frontend_type
)
# Mandatory items from enabled modules only
mandatory_items = mandatory_items & all_items
# No scope specified - return all items (fallback)
if not platform_id and not user_id:
return all_items
shown_items = self._get_shown_items(db, frontend_type, platform_id, user_id)
# If no configuration exists yet, show all items (first-time setup)
if shown_items is None:
return all_items
# Shown items plus mandatory (mandatory are always visible)
# But only if module is enabled
visible = (shown_items | mandatory_items) & all_items
return visible
def _get_shown_items(
self,
db: Session,
frontend_type: FrontendType,
platform_id: int | None = None,
user_id: int | None = None,
) -> set[str] | None:
"""
Get set of shown menu item IDs from database.
Returns:
Set of shown item IDs, or None if no configuration exists.
"""
query = db.query(AdminMenuConfig).filter(
AdminMenuConfig.frontend_type == frontend_type,
)
if platform_id:
query = query.filter(AdminMenuConfig.platform_id == platform_id)
elif user_id:
query = query.filter(AdminMenuConfig.user_id == user_id)
else:
return None
# Check if any config exists for this scope
configs = query.all()
if not configs:
return None # No config = use defaults (all visible)
# Return only items marked as visible
return {c.menu_item_id for c in configs if c.is_visible}
# =========================================================================
# Menu Rendering
# =========================================================================
def get_menu_for_rendering(
self,
db: Session,
frontend_type: FrontendType,
platform_id: int | None = None,
user_id: int | None = None,
is_super_admin: bool = False,
) -> dict:
"""
Get filtered menu structure for frontend rendering.
Filters by:
1. Module enablement (items from disabled modules are removed)
2. Visibility configuration
3. Super admin status
Args:
db: Database session
frontend_type: Which frontend (admin or vendor)
platform_id: Platform ID (for platform admins and vendors)
user_id: User ID (for super admins only)
is_super_admin: Whether user is super admin (affects admin-only sections)
Returns:
Filtered menu structure ready for rendering
"""
registry = (
ADMIN_MENU_REGISTRY if frontend_type == FrontendType.ADMIN else VENDOR_MENU_REGISTRY
)
visible_items = self.get_visible_menu_items(db, frontend_type, platform_id, user_id)
# Deep copy to avoid modifying the registry
filtered_menu = deepcopy(registry)
filtered_sections = []
for section in filtered_menu["sections"]:
# Skip super_admin_only sections if user is not super admin
if section.get("super_admin_only") and not is_super_admin:
continue
# Filter items to only visible ones
# Also skip super_admin_only items if user is not super admin
filtered_items = [
item for item in section["items"]
if item["id"] in visible_items
and (not item.get("super_admin_only") or is_super_admin)
]
# Only include section if it has visible items
if filtered_items:
section["items"] = filtered_items
filtered_sections.append(section)
filtered_menu["sections"] = filtered_sections
return filtered_menu
# =========================================================================
# Menu Configuration (Super Admin)
# =========================================================================
def get_platform_menu_config(
self,
db: Session,
frontend_type: FrontendType,
platform_id: int,
) -> list[MenuItemConfig]:
"""
Get full menu configuration for a platform (for admin UI).
Returns all menu items with their visibility status and module info.
Items from disabled modules are marked with is_module_enabled=False.
Args:
db: Database session
frontend_type: Which frontend (admin or vendor)
platform_id: Platform ID
Returns:
List of MenuItemConfig with current visibility state and module info
"""
from app.modules.registry import get_menu_item_module
registry = (
ADMIN_MENU_REGISTRY if frontend_type == FrontendType.ADMIN else VENDOR_MENU_REGISTRY
)
shown_items = self._get_shown_items(db, frontend_type, platform_id=platform_id)
mandatory_items = MANDATORY_MENU_ITEMS.get(frontend_type, set())
# Get module-available items
module_available_items = module_service.filter_menu_items_by_modules(
db, platform_id, get_all_menu_item_ids(frontend_type), frontend_type
)
result = []
for section in registry["sections"]:
section_id = section["id"]
section_label = section.get("label")
is_super_admin_section = section.get("super_admin_only", False)
for item in section["items"]:
item_id = item["id"]
# Check if module is enabled for this item
is_module_enabled = item_id in module_available_items
module_code = get_menu_item_module(item_id, frontend_type)
# If no config exists (shown_items is None), show all by default
# Otherwise, item is visible if in shown_items or mandatory
# Note: visibility config is independent of module enablement
is_visible = (
shown_items is None
or item_id in shown_items
or item_id in mandatory_items
)
# Item is super admin only if section or item is marked as such
is_item_super_admin_only = is_super_admin_section or item.get("super_admin_only", False)
result.append(
MenuItemConfig(
id=item_id,
label=item["label"],
icon=item["icon"],
url=item["url"],
section_id=section_id,
section_label=section_label,
is_visible=is_visible,
is_mandatory=item_id in mandatory_items,
is_super_admin_only=is_item_super_admin_only,
is_module_enabled=is_module_enabled,
module_code=module_code,
)
)
return result
def get_user_menu_config(
self,
db: Session,
user_id: int,
) -> list[MenuItemConfig]:
"""
Get admin menu configuration for a super admin user.
Super admins don't have platform context, so all modules are shown.
Module enablement is always True for super admin menu config.
Args:
db: Database session
user_id: Super admin user ID
Returns:
List of MenuItemConfig with current visibility state
"""
from app.modules.registry import get_menu_item_module
shown_items = self._get_shown_items(
db, FrontendType.ADMIN, user_id=user_id
)
mandatory_items = MANDATORY_MENU_ITEMS.get(FrontendType.ADMIN, set())
result = []
for section in ADMIN_MENU_REGISTRY["sections"]:
section_id = section["id"]
section_label = section.get("label")
is_super_admin_section = section.get("super_admin_only", False)
for item in section["items"]:
item_id = item["id"]
module_code = get_menu_item_module(item_id, FrontendType.ADMIN)
# If no config exists (shown_items is None), show all by default
# Otherwise, item is visible if in shown_items or mandatory
is_visible = (
shown_items is None
or item_id in shown_items
or item_id in mandatory_items
)
# Item is super admin only if section or item is marked as such
is_item_super_admin_only = is_super_admin_section or item.get("super_admin_only", False)
result.append(
MenuItemConfig(
id=item_id,
label=item["label"],
icon=item["icon"],
url=item["url"],
section_id=section_id,
section_label=section_label,
is_visible=is_visible,
is_mandatory=item_id in mandatory_items,
is_super_admin_only=is_item_super_admin_only,
is_module_enabled=True, # Super admins see all modules
module_code=module_code,
)
)
return result
def update_menu_visibility(
self,
db: Session,
frontend_type: FrontendType,
menu_item_id: str,
is_visible: bool,
platform_id: int | None = None,
user_id: int | None = None,
) -> None:
"""
Update visibility for a menu item (opt-in model).
In the opt-in model:
- is_visible=True: Create/update record to show item
- is_visible=False: Remove record (item hidden by default)
Args:
db: Database session
frontend_type: Which frontend (admin or vendor)
menu_item_id: Menu item identifier
is_visible: Whether the item should be visible
platform_id: Platform ID (for platform-scoped config)
user_id: User ID (for user-scoped config, admin frontend only)
Raises:
ValueError: If menu item is mandatory or doesn't exist
ValueError: If neither platform_id nor user_id is provided
ValueError: If user_id is provided for vendor frontend
"""
# Validate menu item exists
all_items = get_all_menu_item_ids(frontend_type)
if menu_item_id not in all_items:
raise ValueError(f"Unknown menu item: {menu_item_id}")
# Check if mandatory - mandatory items are always visible, no need to store
mandatory = MANDATORY_MENU_ITEMS.get(frontend_type, set())
if menu_item_id in mandatory:
if not is_visible:
raise ValueError(f"Cannot hide mandatory menu item: {menu_item_id}")
# Mandatory items don't need explicit config, they're always visible
return
# Validate scope
if not platform_id and not user_id:
raise ValueError("Either platform_id or user_id must be provided")
if user_id and frontend_type == FrontendType.VENDOR:
raise ValueError("User-scoped config not supported for vendor frontend")
# Find existing config
query = db.query(AdminMenuConfig).filter(
AdminMenuConfig.frontend_type == frontend_type,
AdminMenuConfig.menu_item_id == menu_item_id,
)
if platform_id:
query = query.filter(AdminMenuConfig.platform_id == platform_id)
else:
query = query.filter(AdminMenuConfig.user_id == user_id)
config = query.first()
if is_visible:
# Opt-in: Create or update config to visible (explicitly show)
if config:
config.is_visible = True
else:
config = AdminMenuConfig(
frontend_type=frontend_type,
platform_id=platform_id,
user_id=user_id,
menu_item_id=menu_item_id,
is_visible=True,
)
db.add(config)
logger.info(
f"Set menu config visible: {frontend_type.value}/{menu_item_id} "
f"(platform_id={platform_id}, user_id={user_id})"
)
else:
# Opt-in: Remove config to hide (hidden is default)
if config:
db.delete(config)
logger.info(
f"Removed menu config (hidden): {frontend_type.value}/{menu_item_id} "
f"(platform_id={platform_id}, user_id={user_id})"
)
def bulk_update_menu_visibility(
self,
db: Session,
frontend_type: FrontendType,
visibility_map: dict[str, bool],
platform_id: int | None = None,
user_id: int | None = None,
) -> None:
"""
Update visibility for multiple menu items at once.
Args:
db: Database session
frontend_type: Which frontend (admin or vendor)
visibility_map: Dict of menu_item_id -> is_visible
platform_id: Platform ID (for platform-scoped config)
user_id: User ID (for user-scoped config, admin frontend only)
"""
for menu_item_id, is_visible in visibility_map.items():
try:
self.update_menu_visibility(
db, frontend_type, menu_item_id, is_visible, platform_id, user_id
)
except ValueError as e:
logger.warning(f"Skipping {menu_item_id}: {e}")
def reset_platform_menu_config(
self,
db: Session,
frontend_type: FrontendType,
platform_id: int,
) -> None:
"""
Reset menu configuration for a platform to defaults (all hidden except mandatory).
In opt-in model, reset means hide everything so user can opt-in to what they want.
Args:
db: Database session
frontend_type: Which frontend (admin or vendor)
platform_id: Platform ID
"""
# Delete all existing records
deleted = (
db.query(AdminMenuConfig)
.filter(
AdminMenuConfig.frontend_type == frontend_type,
AdminMenuConfig.platform_id == platform_id,
)
.delete()
)
logger.info(
f"Reset menu config for platform {platform_id} ({frontend_type.value}): "
f"deleted {deleted} rows"
)
# Create records with is_visible=False for all non-mandatory items
# This makes "reset" mean "hide everything except mandatory"
all_items = get_all_menu_item_ids(frontend_type)
mandatory_items = MANDATORY_MENU_ITEMS.get(frontend_type, set())
for item_id in all_items:
if item_id not in mandatory_items:
config = AdminMenuConfig(
frontend_type=frontend_type,
platform_id=platform_id,
user_id=None,
menu_item_id=item_id,
is_visible=False,
)
db.add(config)
logger.info(
f"Created {len(all_items) - len(mandatory_items)} hidden records for platform {platform_id}"
)
def reset_user_menu_config(
self,
db: Session,
user_id: int,
) -> None:
"""
Reset menu configuration for a super admin user to defaults (all hidden except mandatory).
In opt-in model, reset means hide everything so user can opt-in to what they want.
Args:
db: Database session
user_id: Super admin user ID
"""
# Delete all existing records
deleted = (
db.query(AdminMenuConfig)
.filter(
AdminMenuConfig.frontend_type == FrontendType.ADMIN,
AdminMenuConfig.user_id == user_id,
)
.delete()
)
logger.info(f"Reset menu config for user {user_id}: deleted {deleted} rows")
# Create records with is_visible=False for all non-mandatory items
all_items = get_all_menu_item_ids(FrontendType.ADMIN)
mandatory_items = MANDATORY_MENU_ITEMS.get(FrontendType.ADMIN, set())
for item_id in all_items:
if item_id not in mandatory_items:
config = AdminMenuConfig(
frontend_type=FrontendType.ADMIN,
platform_id=None,
user_id=user_id,
menu_item_id=item_id,
is_visible=False,
)
db.add(config)
logger.info(
f"Created {len(all_items) - len(mandatory_items)} hidden records for user {user_id}"
)
def show_all_platform_menu_config(
self,
db: Session,
frontend_type: FrontendType,
platform_id: int,
) -> None:
"""
Show all menu items for a platform.
Args:
db: Database session
frontend_type: Which frontend (admin or vendor)
platform_id: Platform ID
"""
# Delete all existing records
deleted = (
db.query(AdminMenuConfig)
.filter(
AdminMenuConfig.frontend_type == frontend_type,
AdminMenuConfig.platform_id == platform_id,
)
.delete()
)
logger.info(
f"Show all menu config for platform {platform_id} ({frontend_type.value}): "
f"deleted {deleted} rows"
)
# Create records with is_visible=True for all non-mandatory items
all_items = get_all_menu_item_ids(frontend_type)
mandatory_items = MANDATORY_MENU_ITEMS.get(frontend_type, set())
for item_id in all_items:
if item_id not in mandatory_items:
config = AdminMenuConfig(
frontend_type=frontend_type,
platform_id=platform_id,
user_id=None,
menu_item_id=item_id,
is_visible=True,
)
db.add(config)
logger.info(
f"Created {len(all_items) - len(mandatory_items)} visible records for platform {platform_id}"
)
def show_all_user_menu_config(
self,
db: Session,
user_id: int,
) -> None:
"""
Show all menu items for a super admin user.
Args:
db: Database session
user_id: Super admin user ID
"""
# Delete all existing records
deleted = (
db.query(AdminMenuConfig)
.filter(
AdminMenuConfig.frontend_type == FrontendType.ADMIN,
AdminMenuConfig.user_id == user_id,
)
.delete()
)
logger.info(f"Show all menu config for user {user_id}: deleted {deleted} rows")
# Create records with is_visible=True for all non-mandatory items
all_items = get_all_menu_item_ids(FrontendType.ADMIN)
mandatory_items = MANDATORY_MENU_ITEMS.get(FrontendType.ADMIN, set())
for item_id in all_items:
if item_id not in mandatory_items:
config = AdminMenuConfig(
frontend_type=FrontendType.ADMIN,
platform_id=None,
user_id=user_id,
menu_item_id=item_id,
is_visible=True,
)
db.add(config)
logger.info(
f"Created {len(all_items) - len(mandatory_items)} visible records for user {user_id}"
)
def initialize_menu_config(
self,
db: Session,
frontend_type: FrontendType,
platform_id: int | None = None,
user_id: int | None = None,
) -> bool:
"""
Initialize menu configuration with all items visible.
Called when first customizing a menu. Creates records for all items
so the user can then toggle individual items off.
Args:
db: Database session
frontend_type: Which frontend (admin or vendor)
platform_id: Platform ID (for platform-scoped config)
user_id: User ID (for user-scoped config)
Returns:
True if initialized, False if config already exists with visible items
"""
if not platform_id and not user_id:
return False # No scope specified
# Helper to build a fresh query for this scope
def scope_query():
q = db.query(AdminMenuConfig).filter(
AdminMenuConfig.frontend_type == frontend_type,
)
if platform_id:
return q.filter(AdminMenuConfig.platform_id == platform_id)
else:
return q.filter(AdminMenuConfig.user_id == user_id)
# Check if any visible records exist (valid opt-in config)
visible_count = scope_query().filter(
AdminMenuConfig.is_visible == True # noqa: E712
).count()
if visible_count > 0:
logger.debug(f"Config already exists with {visible_count} visible items, skipping init")
return False # Already initialized
# Check if ANY records exist (even is_visible=False from old opt-out model)
total_count = scope_query().count()
if total_count > 0:
# Clean up old records first
deleted = scope_query().delete(synchronize_session='fetch')
db.flush() # Ensure deletes are applied before inserts
logger.info(f"Cleaned up {deleted} old menu config records before initialization")
# Get all menu items for this frontend
all_items = get_all_menu_item_ids(frontend_type)
mandatory_items = MANDATORY_MENU_ITEMS.get(frontend_type, set())
# Create visible records for all non-mandatory items
for item_id in all_items:
if item_id not in mandatory_items:
config = AdminMenuConfig(
frontend_type=frontend_type,
platform_id=platform_id,
user_id=user_id,
menu_item_id=item_id,
is_visible=True,
)
db.add(config)
logger.info(
f"Initialized menu config with {len(all_items) - len(mandatory_items)} items "
f"(platform_id={platform_id}, user_id={user_id})"
)
return True
# Singleton instance
menu_service = MenuService()
__all__ = [
"menu_service",
"MenuService",
"MenuItemConfig",
]

View File

@@ -0,0 +1,176 @@
# app/modules/core/services/platform_settings_service.py
"""
Platform Settings Service
Provides access to platform-wide settings with a resolution chain:
1. AdminSetting from database (can be set via admin UI)
2. Environment variables (from .env/config)
3. Hardcoded defaults
This allows admins to override defaults without code changes,
while still supporting environment-based configuration.
"""
import logging
from typing import Any
from sqlalchemy.orm import Session
from app.core.config import settings
from models.database.admin import AdminSetting
logger = logging.getLogger(__name__)
class PlatformSettingsService:
"""
Service for accessing platform-wide settings.
Resolution order:
1. AdminSetting in database (highest priority)
2. Environment variable via config
3. Hardcoded default (lowest priority)
"""
# Mapping of setting keys to their config attribute names and defaults
SETTINGS_MAP = {
"default_storefront_locale": {
"config_attr": "default_storefront_locale",
"default": "fr-LU",
"description": "Default locale for currency/number formatting (e.g., fr-LU, de-DE)",
"category": "storefront",
},
"default_currency": {
"config_attr": "default_currency",
"default": "EUR",
"description": "Default currency code for the platform",
"category": "storefront",
},
}
def get(self, db: Session, key: str) -> str | None:
"""
Get a setting value with full resolution chain.
Args:
db: Database session
key: Setting key (e.g., 'default_storefront_locale')
Returns:
Setting value or None if not found
"""
# 1. Check AdminSetting in database
admin_setting = db.query(AdminSetting).filter_by(key=key).first()
if admin_setting and admin_setting.value:
logger.debug(f"Setting '{key}' resolved from AdminSetting: {admin_setting.value}")
return admin_setting.value
# 2. Check environment/config
setting_info = self.SETTINGS_MAP.get(key)
if setting_info:
config_attr = setting_info.get("config_attr")
if config_attr and hasattr(settings, config_attr):
value = getattr(settings, config_attr)
logger.debug(f"Setting '{key}' resolved from config: {value}")
return value
# 3. Return hardcoded default
default = setting_info.get("default")
logger.debug(f"Setting '{key}' resolved from default: {default}")
return default
logger.warning(f"Unknown setting key: {key}")
return None
def get_storefront_locale(self, db: Session) -> str:
"""Get the default storefront locale."""
return self.get(db, "default_storefront_locale") or "fr-LU"
def get_currency(self, db: Session) -> str:
"""Get the default currency."""
return self.get(db, "default_currency") or "EUR"
def get_storefront_config(self, db: Session) -> dict[str, str]:
"""
Get all storefront-related settings as a dict.
Returns:
Dict with 'locale' and 'currency' keys
"""
return {
"locale": self.get_storefront_locale(db),
"currency": self.get_currency(db),
}
def set(self, db: Session, key: str, value: str, user_id: int | None = None) -> AdminSetting:
"""
Set a platform setting in the database.
Args:
db: Database session
key: Setting key
value: Setting value
user_id: ID of user making the change (for audit)
Returns:
The created/updated AdminSetting
"""
setting_info = self.SETTINGS_MAP.get(key, {})
admin_setting = db.query(AdminSetting).filter_by(key=key).first()
if admin_setting:
admin_setting.value = value
if user_id:
admin_setting.last_modified_by_user_id = user_id
else:
admin_setting = AdminSetting(
key=key,
value=value,
value_type="string",
category=setting_info.get("category", "system"),
description=setting_info.get("description", ""),
last_modified_by_user_id=user_id,
)
db.add(admin_setting)
db.commit() # noqa: SVC-006 - Setting change is atomic, commit is intentional
db.refresh(admin_setting)
logger.info(f"Platform setting '{key}' set to '{value}' by user {user_id}")
return admin_setting
def get_all_storefront_settings(self, db: Session) -> dict[str, Any]:
"""
Get all storefront settings with their current values and metadata.
Useful for admin UI to display current settings.
Returns:
Dict with setting info including current value and source
"""
result = {}
for key, info in self.SETTINGS_MAP.items():
if info.get("category") == "storefront":
current_value = self.get(db, key)
# Determine source
admin_setting = db.query(AdminSetting).filter_by(key=key).first()
if admin_setting and admin_setting.value:
source = "database"
elif hasattr(settings, info.get("config_attr", "")):
source = "environment"
else:
source = "default"
result[key] = {
"value": current_value,
"source": source,
"description": info.get("description", ""),
"default": info.get("default"),
}
return result
# Singleton instance
platform_settings_service = PlatformSettingsService()

View File

@@ -0,0 +1,295 @@
# app/modules/core/services/storage_service.py
"""
Storage abstraction service for file uploads.
Provides a unified interface for file storage with support for:
- Local filesystem (default, development)
- Cloudflare R2 (production, S3-compatible)
Usage:
from app.modules.core.services import get_storage_backend
storage = get_storage_backend()
url = await storage.upload("path/to/file.jpg", file_bytes, "image/jpeg")
await storage.delete("path/to/file.jpg")
"""
import logging
from abc import ABC, abstractmethod
from pathlib import Path
from app.core.config import settings
logger = logging.getLogger(__name__)
class StorageBackend(ABC):
"""Abstract base class for storage backends."""
@abstractmethod
async def upload(self, file_path: str, content: bytes, content_type: str) -> str:
"""
Upload a file to storage.
Args:
file_path: Relative path where file should be stored
content: File content as bytes
content_type: MIME type of the file
Returns:
Public URL to access the file
"""
pass
@abstractmethod
async def delete(self, file_path: str) -> bool:
"""
Delete a file from storage.
Args:
file_path: Relative path of file to delete
Returns:
True if file was deleted, False if not found
"""
pass
@abstractmethod
def get_url(self, file_path: str) -> str:
"""
Get the public URL for a file.
Args:
file_path: Relative path of the file
Returns:
Public URL to access the file
"""
pass
@abstractmethod
async def exists(self, file_path: str) -> bool:
"""
Check if a file exists in storage.
Args:
file_path: Relative path of the file
Returns:
True if file exists
"""
pass
class LocalStorageBackend(StorageBackend):
"""Local filesystem storage backend."""
def __init__(self, base_dir: str = "uploads"):
"""
Initialize local storage backend.
Args:
base_dir: Base directory for file storage (relative to project root)
"""
self.base_dir = Path(base_dir)
self.base_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"LocalStorageBackend initialized with base_dir: {self.base_dir}")
async def upload(self, file_path: str, content: bytes, content_type: str) -> str:
"""Upload file to local filesystem."""
full_path = self.base_dir / file_path
# Ensure parent directory exists
full_path.parent.mkdir(parents=True, exist_ok=True)
# Write file
full_path.write_bytes(content)
logger.debug(f"Uploaded to local: {file_path} ({len(content)} bytes)")
return self.get_url(file_path)
async def delete(self, file_path: str) -> bool:
"""Delete file from local filesystem."""
full_path = self.base_dir / file_path
if full_path.exists():
full_path.unlink()
logger.debug(f"Deleted from local: {file_path}")
# Clean up empty parent directories
self._cleanup_empty_dirs(full_path.parent)
return True
return False
def get_url(self, file_path: str) -> str:
"""Get URL for local file (served via /uploads mount)."""
return f"/uploads/{file_path}"
async def exists(self, file_path: str) -> bool:
"""Check if file exists locally."""
return (self.base_dir / file_path).exists()
def _cleanup_empty_dirs(self, dir_path: Path) -> None:
"""Remove empty directories up to base_dir."""
try:
while dir_path != self.base_dir and dir_path.exists():
if not any(dir_path.iterdir()):
dir_path.rmdir()
dir_path = dir_path.parent
else:
break
except OSError:
pass
class R2StorageBackend(StorageBackend):
"""Cloudflare R2 storage backend (S3-compatible)."""
def __init__(self):
"""Initialize R2 storage backend."""
import boto3
from botocore.config import Config
if not all([
settings.r2_account_id,
settings.r2_access_key_id,
settings.r2_secret_access_key,
]):
raise ValueError(
"R2 storage requires R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, "
"and R2_SECRET_ACCESS_KEY environment variables"
)
# R2 endpoint URL
endpoint_url = f"https://{settings.r2_account_id}.r2.cloudflarestorage.com"
# Configure boto3 client for R2
self.client = boto3.client(
"s3",
endpoint_url=endpoint_url,
aws_access_key_id=settings.r2_access_key_id,
aws_secret_access_key=settings.r2_secret_access_key,
config=Config(
signature_version="s3v4",
retries={"max_attempts": 3, "mode": "adaptive"},
),
)
self.bucket_name = settings.r2_bucket_name
self.public_url = settings.r2_public_url
logger.info(
f"R2StorageBackend initialized: bucket={self.bucket_name}, "
f"public_url={self.public_url or 'default'}"
)
async def upload(self, file_path: str, content: bytes, content_type: str) -> str:
"""Upload file to R2."""
try:
self.client.put_object(
Bucket=self.bucket_name,
Key=file_path,
Body=content,
ContentType=content_type,
)
logger.debug(f"Uploaded to R2: {file_path} ({len(content)} bytes)")
return self.get_url(file_path)
except Exception as e:
logger.error(f"R2 upload failed for {file_path}: {e}")
raise
async def delete(self, file_path: str) -> bool:
"""Delete file from R2."""
try:
# Check if file exists first
if not await self.exists(file_path):
return False
self.client.delete_object(
Bucket=self.bucket_name,
Key=file_path,
)
logger.debug(f"Deleted from R2: {file_path}")
return True
except Exception as e:
logger.error(f"R2 delete failed for {file_path}: {e}")
return False
def get_url(self, file_path: str) -> str:
"""Get public URL for R2 file."""
if self.public_url:
# Use custom domain
return f"{self.public_url.rstrip('/')}/{file_path}"
else:
# Use default R2 public URL pattern
# Note: Bucket must have public access enabled
return f"https://{self.bucket_name}.{settings.r2_account_id}.r2.dev/{file_path}"
async def exists(self, file_path: str) -> bool:
"""Check if file exists in R2."""
try:
self.client.head_object(Bucket=self.bucket_name, Key=file_path)
return True
except self.client.exceptions.ClientError as e:
if e.response.get("Error", {}).get("Code") == "404":
return False
raise
# =============================================================================
# STORAGE BACKEND FACTORY
# =============================================================================
_storage_backend: StorageBackend | None = None
def get_storage_backend() -> StorageBackend:
"""
Get the configured storage backend instance.
Returns:
Storage backend based on STORAGE_BACKEND setting
Raises:
ValueError: If storage backend is misconfigured
"""
global _storage_backend
if _storage_backend is not None:
return _storage_backend
backend_type = settings.storage_backend.lower()
if backend_type == "r2":
_storage_backend = R2StorageBackend()
elif backend_type == "local":
_storage_backend = LocalStorageBackend()
else:
raise ValueError(f"Unknown storage backend: {backend_type}")
return _storage_backend
def reset_storage_backend() -> None:
"""Reset the storage backend (useful for testing)."""
global _storage_backend
_storage_backend = None
# =============================================================================
# PUBLIC API
# =============================================================================
__all__ = [
"StorageBackend",
"LocalStorageBackend",
"R2StorageBackend",
"get_storage_backend",
"reset_storage_backend",
]

View File

@@ -0,0 +1,191 @@
// noqa: js-006 - async init pattern is safe, loadData has try/catch
// static/admin/js/dashboard.js
// ✅ Use centralized logger - ONE LINE!
const dashLog = window.LogConfig.loggers.dashboard;
function adminDashboard() {
return {
// Inherit base layout functionality from init-alpine.js
...data(),
// Dashboard-specific state
currentPage: 'dashboard',
stats: {
totalVendors: 0,
activeUsers: 0,
verifiedVendors: 0,
importJobs: 0
},
recentVendors: [],
loading: true,
error: null,
/**
* Initialize dashboard
*/
async init() {
// Guard against multiple initialization
if (window._dashboardInitialized) {
dashLog.warn('Dashboard already initialized, skipping...');
return;
}
window._dashboardInitialized = true;
dashLog.info('=== DASHBOARD INITIALIZING ===');
dashLog.debug('Current URL:', window.location.href);
dashLog.debug('Current pathname:', window.location.pathname);
const token = localStorage.getItem('admin_token');
dashLog.debug('Has admin_token?', !!token);
if (token) {
dashLog.debug('Token preview:', token.substring(0, 20) + '...');
}
dashLog.debug('Dashboard initialization flag set');
const startTime = performance.now();
await this.loadDashboard();
const duration = performance.now() - startTime;
window.LogConfig.logPerformance('Dashboard Init', duration);
dashLog.info('=== DASHBOARD INITIALIZATION COMPLETE ===');
},
/**
* Load all dashboard data
*/
async loadDashboard() {
dashLog.info('Loading dashboard data...');
this.loading = true;
this.error = null;
dashLog.debug('Dashboard state: loading=true, error=null');
try {
dashLog.group('Loading dashboard data');
const startTime = performance.now();
// Load stats and vendors in parallel
await Promise.all([
this.loadStats(),
this.loadRecentVendors()
]);
const duration = performance.now() - startTime;
dashLog.groupEnd();
window.LogConfig.logPerformance('Load Dashboard Data', duration);
dashLog.info(`Dashboard data loaded successfully in ${duration}ms`);
} catch (error) {
window.LogConfig.logError(error, 'Dashboard Load');
this.error = error.message;
Utils.showToast('Failed to load dashboard data', 'error');
} finally {
this.loading = false;
dashLog.debug('Dashboard state: loading=false');
dashLog.info('Dashboard load attempt finished');
}
},
/**
* Load platform statistics
*/
async loadStats() {
dashLog.info('Loading platform statistics...');
const url = '/admin/dashboard/stats/platform';
window.LogConfig.logApiCall('GET', url, null, 'request');
try {
const startTime = performance.now();
const data = await apiClient.get(url);
const duration = performance.now() - startTime;
window.LogConfig.logApiCall('GET', url, data, 'response');
window.LogConfig.logPerformance('Load Stats', duration);
// Map API response to stats cards
this.stats = {
totalVendors: data.vendors?.total_vendors || 0,
activeUsers: data.users?.active_users || 0,
verifiedVendors: data.vendors?.verified_vendors || 0,
importJobs: data.imports?.total_imports || 0
};
dashLog.info('Stats mapped:', this.stats);
} catch (error) {
dashLog.error('Failed to load stats:', error);
throw error;
}
},
/**
* Load recent vendors
*/
async loadRecentVendors() {
dashLog.info('Loading recent vendors...');
const url = '/admin/dashboard';
window.LogConfig.logApiCall('GET', url, null, 'request');
try {
const startTime = performance.now();
const data = await apiClient.get(url);
const duration = performance.now() - startTime;
window.LogConfig.logApiCall('GET', url, data, 'response');
window.LogConfig.logPerformance('Load Recent Vendors', duration);
this.recentVendors = data.recent_vendors || [];
if (this.recentVendors.length > 0) {
dashLog.info(`Loaded ${this.recentVendors.length} recent vendors`);
dashLog.debug('First vendor:', this.recentVendors[0]);
} else {
dashLog.warn('No recent vendors found');
}
} catch (error) {
dashLog.error('Failed to load recent vendors:', error);
throw error;
}
},
/**
* Format date for display
*/
formatDate(dateString) {
if (!dateString) {
dashLog.debug('formatDate called with empty dateString');
return '-';
}
const formatted = Utils.formatDate(dateString);
dashLog.debug(`Date formatted: ${dateString} -> ${formatted}`);
return formatted;
},
/**
* Navigate to vendor detail page
*/
viewVendor(vendorCode) {
dashLog.info('Navigating to vendor:', vendorCode);
const url = `/admin/vendors?code=${vendorCode}`;
dashLog.debug('Navigation URL:', url);
window.location.href = url;
},
/**
* Refresh dashboard data
*/
async refresh() {
dashLog.info('=== DASHBOARD REFRESH TRIGGERED ===');
await this.loadDashboard();
Utils.showToast('Dashboard refreshed', 'success');
dashLog.info('=== DASHBOARD REFRESH COMPLETE ===');
}
};
}
dashLog.info('Dashboard module loaded');

View File

@@ -0,0 +1,428 @@
/**
* Alpine.js v3 global data initialization
* Provides theme toggle, menu controls, sidebar sections, and page state
*/
function data() {
// ─────────────────────────────────────────────────────────────────
// Theme (dark mode) persistence
// ─────────────────────────────────────────────────────────────────
function getThemeFromLocalStorage() {
if (window.localStorage.getItem('dark')) {
return JSON.parse(window.localStorage.getItem('dark'))
}
return (
!!window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches
)
}
function setThemeToLocalStorage(value) {
window.localStorage.setItem('dark', value)
}
// ─────────────────────────────────────────────────────────────────
// Sidebar sections persistence
// ─────────────────────────────────────────────────────────────────
const SIDEBAR_STORAGE_KEY = 'admin_sidebar_sections';
// Default state: Platform Administration open, others closed
const defaultSections = {
superAdmin: true, // Super admin section (only visible to super admins)
platformAdmin: true,
vendorOps: false,
marketplace: false,
billing: false,
contentMgmt: false,
devTools: false,
platformHealth: false,
monitoring: false,
settingsSection: false
};
function getSidebarSectionsFromStorage() {
try {
const stored = window.localStorage.getItem(SIDEBAR_STORAGE_KEY);
if (stored) {
return { ...defaultSections, ...JSON.parse(stored) };
}
} catch (e) {
console.warn('Failed to parse sidebar sections from localStorage:', e);
}
return { ...defaultSections };
}
function saveSidebarSectionsToStorage(sections) {
try {
window.localStorage.setItem(SIDEBAR_STORAGE_KEY, JSON.stringify(sections));
} catch (e) {
console.warn('Failed to save sidebar sections to localStorage:', e);
}
}
// ─────────────────────────────────────────────────────────────────
// Last visited page tracking (for redirect after login)
// ─────────────────────────────────────────────────────────────────
const LAST_PAGE_KEY = 'admin_last_visited_page';
const currentPath = window.location.pathname;
// Save current page (exclude login, logout, error pages)
if (currentPath.startsWith('/admin/') &&
!currentPath.includes('/login') &&
!currentPath.includes('/logout') &&
!currentPath.includes('/errors/')) {
try {
window.localStorage.setItem(LAST_PAGE_KEY, currentPath);
} catch (e) {
// Ignore storage errors
}
}
// Helper to get admin profile from localStorage
function getAdminProfileFromStorage() {
try {
// Check admin_user first (set by login), then adminProfile (legacy)
const stored = window.localStorage.getItem('admin_user') ||
window.localStorage.getItem('adminProfile');
if (stored) {
return JSON.parse(stored);
}
} catch (e) {
console.warn('Failed to parse admin profile from localStorage:', e);
}
return null;
}
// Map pages to their parent sections
const pageSectionMap = {
// Super Admin section
'admin-users': 'superAdmin',
// Platform Administration
companies: 'platformAdmin',
vendors: 'platformAdmin',
messages: 'platformAdmin',
// Vendor Operations (Products, Customers, Inventory, Orders, Shipping)
'marketplace-products': 'vendorOps',
'vendor-products': 'vendorOps',
customers: 'vendorOps',
inventory: 'vendorOps',
orders: 'vendorOps',
// Future: shipping will map to 'vendorOps'
// Marketplace
'marketplace-letzshop': 'marketplace',
// Content Management
'platform-homepage': 'contentMgmt',
'content-pages': 'contentMgmt',
'vendor-theme': 'contentMgmt',
// Developer Tools
components: 'devTools',
icons: 'devTools',
// Platform Health
testing: 'platformHealth',
'code-quality': 'platformHealth',
// Platform Monitoring
imports: 'monitoring',
'background-tasks': 'monitoring',
logs: 'monitoring',
'notifications-settings': 'monitoring',
// Platform Settings
settings: 'settingsSection',
profile: 'settingsSection',
'api-keys': 'settingsSection'
};
return {
// ─────────────────────────────────────────────────────────────────
// Theme
// ─────────────────────────────────────────────────────────────────
dark: getThemeFromLocalStorage(),
toggleTheme() {
this.dark = !this.dark
setThemeToLocalStorage(this.dark)
},
// ─────────────────────────────────────────────────────────────────
// Mobile side menu
// ─────────────────────────────────────────────────────────────────
isSideMenuOpen: false,
toggleSideMenu() {
this.isSideMenuOpen = !this.isSideMenuOpen
},
closeSideMenu() {
this.isSideMenuOpen = false
},
// ─────────────────────────────────────────────────────────────────
// Notifications menu
// ─────────────────────────────────────────────────────────────────
isNotificationsMenuOpen: false,
toggleNotificationsMenu() {
this.isNotificationsMenuOpen = !this.isNotificationsMenuOpen
},
closeNotificationsMenu() {
this.isNotificationsMenuOpen = false
},
// ─────────────────────────────────────────────────────────────────
// Profile menu
// ─────────────────────────────────────────────────────────────────
isProfileMenuOpen: false,
toggleProfileMenu() {
this.isProfileMenuOpen = !this.isProfileMenuOpen
},
closeProfileMenu() {
this.isProfileMenuOpen = false
},
// ─────────────────────────────────────────────────────────────────
// Pages menu (legacy)
// ─────────────────────────────────────────────────────────────────
isPagesMenuOpen: false,
togglePagesMenu() {
this.isPagesMenuOpen = !this.isPagesMenuOpen
},
// ─────────────────────────────────────────────────────────────────
// Collapsible sidebar sections
// ─────────────────────────────────────────────────────────────────
openSections: getSidebarSectionsFromStorage(),
toggleSection(section) {
this.openSections[section] = !this.openSections[section];
saveSidebarSectionsToStorage(this.openSections);
},
// Auto-expand section containing current page
expandSectionForCurrentPage() {
const section = pageSectionMap[this.currentPage];
if (section && !this.openSections[section]) {
this.openSections[section] = true;
saveSidebarSectionsToStorage(this.openSections);
}
},
// ─────────────────────────────────────────────────────────────────
// Page identifier - will be set by individual pages
// ─────────────────────────────────────────────────────────────────
currentPage: '',
// ─────────────────────────────────────────────────────────────────
// Admin profile and super admin flag
// ─────────────────────────────────────────────────────────────────
adminProfile: getAdminProfileFromStorage(),
get isSuperAdmin() {
return this.adminProfile?.is_super_admin === true;
},
// ─────────────────────────────────────────────────────────────────
// Dynamic menu visibility (loaded from API)
// ─────────────────────────────────────────────────────────────────
menuData: null,
menuLoading: false,
visibleMenuItems: new Set(),
async loadMenuConfig(forceReload = false) {
// Don't reload if already loaded (unless forced)
if (!forceReload && (this.menuData || this.menuLoading)) return;
// Skip if apiClient is not available (e.g., on login page)
if (typeof apiClient === 'undefined') {
console.debug('Menu config: apiClient not available');
return;
}
// Skip if not authenticated
if (!localStorage.getItem('admin_token')) {
console.debug('Menu config: no admin_token, skipping');
return;
}
this.menuLoading = true;
try {
this.menuData = await apiClient.get('/admin/menu-config/render/admin');
// Build a set of visible menu item IDs for quick lookup
this.visibleMenuItems = new Set();
for (const section of (this.menuData?.sections || [])) {
for (const item of (section.items || [])) {
this.visibleMenuItems.add(item.id);
}
}
console.debug('Menu config loaded:', this.visibleMenuItems.size, 'items');
} catch (e) {
// Silently fail - menu will show all items as fallback
console.debug('Menu config not loaded, using defaults:', e?.message || e);
} finally {
this.menuLoading = false;
}
},
async reloadSidebarMenu() {
// Force reload the sidebar menu config
this.menuData = null;
this.visibleMenuItems = new Set();
await this.loadMenuConfig(true);
},
isMenuItemVisible(menuItemId) {
// If menu not loaded yet, show all items (fallback to hardcoded)
if (!this.menuData) return true;
return this.visibleMenuItems.has(menuItemId);
},
isSectionVisible(sectionId) {
// If menu not loaded yet, show all sections
if (!this.menuData) return true;
// Check if any item in this section is visible
const section = this.menuData?.sections?.find(s => s.id === sectionId);
return section && section.items && section.items.length > 0;
}
}
}
/**
* Language selector component for i18n support
* Used by language_selector macros in templates
*
* @param {string} currentLang - Current language code (e.g., 'fr')
* @param {Array} enabledLanguages - Array of enabled language codes
* @returns {Object} Alpine.js component data
*/
function languageSelector(currentLang, enabledLanguages) {
return {
isLangOpen: false,
currentLang: currentLang || 'fr',
languages: enabledLanguages || ['fr', 'de', 'en'],
languageNames: {
'en': 'English',
'fr': 'Français',
'de': 'Deutsch',
'lb': 'Lëtzebuergesch'
},
languageFlags: {
'en': 'gb',
'fr': 'fr',
'de': 'de',
'lb': 'lu'
},
async setLanguage(lang) {
if (lang === this.currentLang) {
this.isLangOpen = false;
return;
}
try {
const response = await fetch('/api/v1/language/set', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ language: lang })
});
if (response.ok) {
this.currentLang = lang;
window.location.reload();
}
} catch (error) {
console.error('Failed to set language:', error);
}
this.isLangOpen = false;
}
};
}
// Export to window for use in templates
window.languageSelector = languageSelector;
/**
* Header messages badge component
* Shows unread message count in header
*/
function headerMessages() {
return {
unreadCount: 0,
pollInterval: null,
async init() {
await this.fetchUnreadCount();
// Poll every 30 seconds
this.pollInterval = setInterval(() => this.fetchUnreadCount(), 30000);
},
destroy() {
if (this.pollInterval) {
clearInterval(this.pollInterval);
}
},
async fetchUnreadCount() {
try {
const response = await apiClient.get('/admin/messages/unread-count');
this.unreadCount = response.total_unread || 0;
} catch (error) {
// Silently fail - don't spam console for auth issues on login page
}
}
};
}
// Export to window
window.headerMessages = headerMessages;
/**
* Platform Settings Utility
* Provides cached access to platform-wide settings
*/
const PlatformSettings = {
// Cache key and TTL
CACHE_KEY: 'platform_settings_cache',
CACHE_TTL: 5 * 60 * 1000, // 5 minutes
/**
* Get cached settings or fetch from API
*/
async get() {
try {
const cached = localStorage.getItem(this.CACHE_KEY);
if (cached) {
const { data, timestamp } = JSON.parse(cached);
if (Date.now() - timestamp < this.CACHE_TTL) {
return data;
}
}
// Fetch from API
const response = await apiClient.get('/admin/settings/display/public');
const settings = {
rows_per_page: response.rows_per_page || 20
};
// Cache the result
localStorage.setItem(this.CACHE_KEY, JSON.stringify({
data: settings,
timestamp: Date.now()
}));
return settings;
} catch (error) {
console.warn('Failed to load platform settings, using defaults:', error);
return { rows_per_page: 20 };
}
},
/**
* Get rows per page setting
*/
async getRowsPerPage() {
const settings = await this.get();
return settings.rows_per_page;
},
/**
* Clear the cache (call after saving settings)
*/
clearCache() {
localStorage.removeItem(this.CACHE_KEY);
}
};
// Export to window
window.PlatformSettings = PlatformSettings;

View File

@@ -0,0 +1,246 @@
// static/admin/js/login.js
// noqa: js-003 - Standalone login page, doesn't use base layout
// noqa: js-004 - No sidebar on login page, doesn't need currentPage
// ✅ Use centralized logger - ONE LINE!
// Create custom logger for login page
const loginLog = window.LogConfig.createLogger('LOGIN');
function adminLogin() {
return {
dark: false,
credentials: {
username: '',
password: ''
},
loading: false,
error: null,
success: null,
errors: {},
init() {
// Guard against multiple initialization
if (window._adminLoginInitialized) return;
window._adminLoginInitialized = true;
loginLog.info('=== LOGIN PAGE INITIALIZING ===');
loginLog.debug('Current pathname:', window.location.pathname);
loginLog.debug('Current URL:', window.location.href);
// Just set theme - NO auth checking, NO token clearing!
this.dark = localStorage.getItem('theme') === 'dark';
loginLog.debug('Dark mode:', this.dark);
// DON'T clear tokens on init!
// If user lands here with a valid token, they might be navigating manually
// or got redirected. Let them try to login or navigate away.
const token = localStorage.getItem('admin_token');
if (token) {
loginLog.warn('Found existing token on login page');
loginLog.debug('Token preview:', token.substring(0, 20) + '...');
loginLog.info('Not clearing token - user may have navigated here manually');
} else {
loginLog.debug('No existing token found');
}
loginLog.info('=== LOGIN PAGE INITIALIZATION COMPLETE ===');
},
clearTokens() {
loginLog.debug('Clearing all auth tokens...');
const tokensBefore = {
admin_token: !!localStorage.getItem('admin_token'),
admin_user: !!localStorage.getItem('admin_user'),
token: !!localStorage.getItem('token')
};
loginLog.debug('Tokens before clear:', tokensBefore);
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_user');
localStorage.removeItem('token');
const tokensAfter = {
admin_token: !!localStorage.getItem('admin_token'),
admin_user: !!localStorage.getItem('admin_user'),
token: !!localStorage.getItem('token')
};
loginLog.debug('Tokens after clear:', tokensAfter);
},
clearErrors() {
loginLog.debug('Clearing form errors');
this.error = null;
this.success = null;
this.errors = {};
},
validateForm() {
loginLog.debug('Validating login form...');
this.clearErrors();
let isValid = true;
if (!this.credentials.username.trim()) {
this.errors.username = 'Username is required';
loginLog.warn('Validation failed: Username is required');
isValid = false;
}
if (!this.credentials.password) {
this.errors.password = 'Password is required';
loginLog.warn('Validation failed: Password is required');
isValid = false;
} else if (this.credentials.password.length < 6) {
this.errors.password = 'Password must be at least 6 characters';
loginLog.warn('Validation failed: Password too short');
isValid = false;
}
loginLog.info('Form validation result:', isValid ? 'VALID' : 'INVALID');
return isValid;
},
async handleLogin() {
loginLog.info('=== LOGIN ATTEMPT STARTED ===');
if (!this.validateForm()) {
loginLog.warn('Form validation failed, aborting login');
return;
}
this.loading = true;
this.clearErrors();
loginLog.debug('Login state set to loading');
try {
loginLog.info('Calling login API endpoint...');
loginLog.debug('Username:', this.credentials.username);
const url = '/admin/auth/login';
const payload = {
email_or_username: this.credentials.username.trim(),
password: this.credentials.password
};
window.LogConfig.logApiCall('POST', url, { username: payload.username }, 'request');
const startTime = performance.now();
const response = await apiClient.post(url, payload);
const duration = performance.now() - startTime;
window.LogConfig.logApiCall('POST', url, {
hasToken: !!response.access_token,
user: response.user?.username
}, 'response');
window.LogConfig.logPerformance('Login', duration);
loginLog.info(`Login API response received in ${duration}ms`);
loginLog.debug('Response structure:', {
hasToken: !!response.access_token,
hasUser: !!response.user,
userRole: response.user?.role,
userName: response.user?.username
});
// Validate response
if (!response.access_token) {
loginLog.error('Invalid response: No access token');
throw new Error('Invalid response from server - no token');
}
if (response.user && response.user.role !== 'admin') {
loginLog.error('Authorization failed: User is not admin', {
actualRole: response.user.role
});
throw new Error('Access denied. Admin privileges required.');
}
loginLog.info('Login successful, storing authentication data...');
// Store authentication data
localStorage.setItem('admin_token', response.access_token);
localStorage.setItem('token', response.access_token);
loginLog.debug('Token stored, length:', response.access_token.length);
if (response.user) {
localStorage.setItem('admin_user', JSON.stringify(response.user));
loginLog.debug('User data stored:', {
username: response.user.username,
role: response.user.role,
id: response.user.id,
is_super_admin: response.user.is_super_admin
});
}
// Verify storage
const storedToken = localStorage.getItem('admin_token');
const storedUser = localStorage.getItem('admin_user');
loginLog.info('Storage verification:', {
tokenStored: !!storedToken,
userStored: !!storedUser,
tokenLength: storedToken?.length
});
// Show success message
this.success = 'Login successful! Checking platform access...';
loginLog.info('Success message displayed to user');
// Check if platform selection is required
try {
loginLog.info('Checking accessible platforms...');
const platformsResponse = await apiClient.get('/admin/auth/accessible-platforms');
loginLog.debug('Accessible platforms response:', platformsResponse);
if (platformsResponse.requires_platform_selection) {
// Platform admin needs to select a platform
loginLog.info('Platform selection required, redirecting...');
this.success = 'Login successful! Please select a platform...';
window.location.href = '/admin/select-platform';
return;
}
} catch (platformError) {
loginLog.warn('Could not check platforms, proceeding to dashboard:', platformError);
}
// Super admin or single platform - proceed to dashboard
this.success = 'Login successful! Redirecting...';
// Check for last visited page (saved before logout)
const lastPage = localStorage.getItem('admin_last_visited_page');
const redirectTo = (lastPage && lastPage.startsWith('/admin/') && !lastPage.includes('/login') && !lastPage.includes('/select-platform'))
? lastPage
: '/admin/dashboard';
loginLog.info('=== EXECUTING REDIRECT ===');
loginLog.debug('Last visited page:', lastPage);
loginLog.debug('Target URL:', redirectTo);
// Use href instead of replace to allow back button
window.location.href = redirectTo;
} catch (error) {
window.LogConfig.logError(error, 'Login');
this.error = error.message || 'Invalid username or password. Please try again.';
loginLog.info('Error message displayed to user:', this.error);
// Only clear tokens on login FAILURE
this.clearTokens();
loginLog.info('Tokens cleared after error');
} finally {
this.loading = false;
loginLog.debug('Login state set to not loading');
loginLog.info('=== LOGIN ATTEMPT FINISHED ===');
}
},
toggleDarkMode() {
loginLog.debug('Toggling dark mode...');
this.dark = !this.dark;
localStorage.setItem('theme', this.dark ? 'dark' : 'light');
loginLog.info('Dark mode:', this.dark ? 'ON' : 'OFF');
}
}
}
loginLog.info('Login module loaded');

View File

@@ -0,0 +1,187 @@
// static/admin/js/my-menu-config.js
// Personal menu configuration for super admins
//
// TODO: BUG - Sidebar menu doesn't update immediately after changes.
// User must navigate to another page to see the updated menu.
// The issue is that Alpine.js doesn't properly track reactivity for the
// visibleMenuItems Set in init-alpine.js. Attempted fixes with reloadSidebarMenu()
// and window.location.reload() didn't work reliably.
// Possible solutions:
// 1. Convert visibleMenuItems from Set to plain object for better Alpine reactivity
// 2. Use Alpine.store() for shared state between components
// 3. Dispatch a custom event that the sidebar listens for
// 4. Force re-render of sidebar component after changes
const myMenuConfigLog = window.LogConfig?.loggers?.myMenuConfig || window.LogConfig?.createLogger?.('myMenuConfig') || console;
function adminMyMenuConfig() {
return {
// Inherit base layout functionality from init-alpine.js
...data(),
// Page-specific state
currentPage: 'my-menu',
loading: true,
error: null,
successMessage: null,
saving: false,
// Data
menuConfig: null,
// Computed grouped items
get groupedItems() {
if (!this.menuConfig?.items) return [];
// Group items by section
const sections = {};
for (const item of this.menuConfig.items) {
const sectionId = item.section_id;
if (!sections[sectionId]) {
sections[sectionId] = {
id: sectionId,
label: item.section_label,
isSuperAdminOnly: item.is_super_admin_only,
items: [],
visibleCount: 0
};
}
sections[sectionId].items.push(item);
if (item.is_visible) {
sections[sectionId].visibleCount++;
}
}
// Convert to array and maintain order
return Object.values(sections);
},
async init() {
// Guard against multiple initialization
if (window._adminMyMenuConfigInitialized) {
myMenuConfigLog.warn('Already initialized, skipping');
return;
}
window._adminMyMenuConfigInitialized = true;
myMenuConfigLog.info('=== MY MENU CONFIG PAGE INITIALIZING ===');
try {
await this.loadMenuConfig();
myMenuConfigLog.info('=== MY MENU CONFIG PAGE INITIALIZED ===');
} catch (error) {
myMenuConfigLog.error('Failed to initialize my menu config page:', error);
this.error = 'Failed to load page data. Please refresh.';
}
},
async refresh() {
this.error = null;
this.successMessage = null;
await this.loadMenuConfig();
},
async loadMenuConfig() {
this.loading = true;
this.error = null;
try {
this.menuConfig = await apiClient.get('/admin/menu-config/user');
myMenuConfigLog.info('Loaded menu config:', {
totalItems: this.menuConfig?.total_items,
visibleItems: this.menuConfig?.visible_items
});
} catch (error) {
myMenuConfigLog.error('Failed to load menu config:', error);
this.error = error.message || 'Failed to load menu configuration';
} finally {
this.loading = false;
}
},
async toggleVisibility(item) {
if (item.is_mandatory) {
myMenuConfigLog.warn('Cannot toggle mandatory item:', item.id);
return;
}
this.saving = true;
this.error = null;
this.successMessage = null;
const newVisibility = !item.is_visible;
try {
await apiClient.put('/admin/menu-config/user', {
menu_item_id: item.id,
is_visible: newVisibility
});
// Update local state
item.is_visible = newVisibility;
// Update counts
if (newVisibility) {
this.menuConfig.visible_items++;
this.menuConfig.hidden_items--;
} else {
this.menuConfig.visible_items--;
this.menuConfig.hidden_items++;
}
myMenuConfigLog.info('Toggled visibility:', item.id, newVisibility);
// Reload the page to refresh sidebar
window.location.reload();
} catch (error) {
myMenuConfigLog.error('Failed to toggle visibility:', error);
this.error = error.message || 'Failed to update menu visibility';
this.saving = false;
}
},
async showAll() {
if (!confirm('This will show all menu items. Continue?')) {
return;
}
this.saving = true;
this.error = null;
this.successMessage = null;
try {
await apiClient.post('/admin/menu-config/user/show-all');
myMenuConfigLog.info('Showed all menu items');
// Reload the page to refresh sidebar
window.location.reload();
} catch (error) {
myMenuConfigLog.error('Failed to show all menu items:', error);
this.error = error.message || 'Failed to show all menu items';
this.saving = false;
}
},
async resetToDefaults() {
if (!confirm('This will hide all menu items (except mandatory ones). You can then enable the ones you want. Continue?')) {
return;
}
this.saving = true;
this.error = null;
this.successMessage = null;
try {
await apiClient.post('/admin/menu-config/user/reset');
myMenuConfigLog.info('Reset menu config to defaults');
// Reload the page to refresh sidebar
window.location.reload();
} catch (error) {
myMenuConfigLog.error('Failed to reset menu config:', error);
this.error = error.message || 'Failed to reset menu configuration';
this.saving = false;
}
}
};
}

View File

@@ -0,0 +1,509 @@
// static/admin/js/settings.js
// noqa: JS-003 - Uses ...baseData which is data() with safety check
const settingsLog = window.LogConfig?.loggers?.settings || console;
function adminSettings() {
// Get base data with safety check for standalone usage
const baseData = typeof data === 'function' ? data() : {};
return {
// Inherit base layout functionality from init-alpine.js
...baseData,
// Settings-specific state
currentPage: 'settings',
loading: true,
saving: false,
error: null,
successMessage: null,
activeTab: 'display',
displaySettings: {
rows_per_page: 20
},
logSettings: {
log_level: 'INFO',
log_file_max_size_mb: 10,
log_file_backup_count: 5,
db_log_retention_days: 30,
file_logging_enabled: true,
db_logging_enabled: true
},
notificationSettings: {
email_enabled: true,
in_app_enabled: true,
critical_only: false
},
shippingSettings: {
carrier_greco_label_url: 'https://dispatchweb.fr/Tracky/Home/',
carrier_colissimo_label_url: '',
carrier_xpresslogistics_label_url: ''
},
emailSettings: {
provider: 'smtp',
from_email: '',
from_name: '',
reply_to: '',
smtp_host: '',
smtp_port: 587,
smtp_user: '',
mailgun_domain: '',
aws_region: '',
debug: false,
enabled: true,
is_configured: false,
has_db_overrides: false
},
// Email editing form (separate from display to track changes)
emailForm: {
provider: 'smtp',
from_email: '',
from_name: '',
reply_to: '',
smtp_host: '',
smtp_port: 587,
smtp_user: '',
smtp_password: '',
smtp_use_tls: true,
smtp_use_ssl: false,
sendgrid_api_key: '',
mailgun_api_key: '',
mailgun_domain: '',
aws_access_key_id: '',
aws_secret_access_key: '',
aws_region: 'eu-west-1',
enabled: true,
debug: false
},
emailEditMode: false,
testEmailAddress: '',
sendingTestEmail: false,
testEmailError: null,
testEmailSuccess: null,
async init() {
// Guard against multiple initialization
if (window._adminSettingsInitialized) return;
window._adminSettingsInitialized = true;
try {
settingsLog.info('=== SETTINGS PAGE INITIALIZING ===');
await Promise.all([
this.loadDisplaySettings(),
this.loadLogSettings(),
this.loadShippingSettings(),
this.loadEmailSettings()
]);
} catch (error) {
settingsLog.error('Init failed:', error);
this.error = 'Failed to initialize settings page';
}
},
async refresh() {
this.error = null;
this.successMessage = null;
await Promise.all([
this.loadDisplaySettings(),
this.loadLogSettings(),
this.loadShippingSettings(),
this.loadEmailSettings()
]);
},
async loadDisplaySettings() {
try {
const data = await apiClient.get('/admin/settings/display/rows-per-page');
this.displaySettings.rows_per_page = data.rows_per_page || 20;
settingsLog.info('Display settings loaded:', this.displaySettings);
} catch (error) {
settingsLog.error('Failed to load display settings:', error);
// Use default value on error
this.displaySettings.rows_per_page = 20;
}
},
async saveDisplaySettings() {
this.saving = true;
this.error = null;
this.successMessage = null;
try {
const data = await apiClient.put(`/admin/settings/display/rows-per-page?rows=${this.displaySettings.rows_per_page}`);
this.successMessage = data.message || 'Display settings saved successfully';
// Clear the cached platform settings so pages pick up the new value
if (window.PlatformSettings) {
window.PlatformSettings.clearCache();
}
// Auto-hide success message after 5 seconds
setTimeout(() => {
this.successMessage = null;
}, 5000);
settingsLog.info('Display settings saved successfully');
} catch (error) {
settingsLog.error('Failed to save display settings:', error);
this.error = error.response?.data?.detail || 'Failed to save display settings';
} finally {
this.saving = false;
}
},
async loadLogSettings() {
this.loading = true;
this.error = null;
try {
const data = await apiClient.get('/admin/logs/settings');
this.logSettings = data;
settingsLog.info('Log settings loaded:', this.logSettings);
} catch (error) {
settingsLog.error('Failed to load log settings:', error);
this.error = error.response?.data?.detail || 'Failed to load log settings';
} finally {
this.loading = false;
}
},
async saveLogSettings() {
this.saving = true;
this.error = null;
this.successMessage = null;
try {
const data = await apiClient.put('/admin/logs/settings', this.logSettings);
this.successMessage = data.message || 'Log settings saved successfully';
// Auto-hide success message after 5 seconds
setTimeout(() => {
this.successMessage = null;
}, 5000);
settingsLog.info('Log settings saved successfully');
} catch (error) {
settingsLog.error('Failed to save log settings:', error);
this.error = error.response?.data?.detail || 'Failed to save log settings';
} finally {
this.saving = false;
}
},
async cleanupOldLogs() {
if (!confirm(`This will delete all logs older than ${this.logSettings.db_log_retention_days} days. Continue?`)) {
return;
}
this.error = null;
this.successMessage = null;
try {
const data = await apiClient.delete(
`/admin/logs/database/cleanup?retention_days=${this.logSettings.db_log_retention_days}&confirm=true`
);
this.successMessage = data.message || 'Old logs cleaned up successfully';
// Auto-hide success message after 5 seconds
setTimeout(() => {
this.successMessage = null;
}, 5000);
settingsLog.info('Old logs cleaned up successfully');
} catch (error) {
settingsLog.error('Failed to cleanup logs:', error);
this.error = error.response?.data?.detail || 'Failed to cleanup old logs';
}
},
async saveNotificationSettings() {
this.saving = true;
this.error = null;
this.successMessage = null;
try {
// TODO: Implement API endpoint for notification settings
// const data = await apiClient.put('/admin/notifications/settings', this.notificationSettings);
// For now, just show success (settings are client-side only)
this.successMessage = 'Notification settings saved successfully';
// Auto-hide success message after 5 seconds
setTimeout(() => {
this.successMessage = null;
}, 5000);
settingsLog.info('Notification settings saved:', this.notificationSettings);
} catch (error) {
settingsLog.error('Failed to save notification settings:', error);
this.error = error.response?.data?.detail || 'Failed to save notification settings';
} finally {
this.saving = false;
}
},
async loadShippingSettings() {
try {
// Load each carrier setting with defaults to avoid 404 errors
const carriers = [
{ name: 'greco', default: 'https://dispatchweb.fr/Tracky/Home/' },
{ name: 'colissimo', default: '' },
{ name: 'xpresslogistics', default: '' }
];
for (const carrier of carriers) {
const key = `carrier_${carrier.name}_label_url`;
// Use default query param to avoid 404 for non-existent settings
const data = await apiClient.get(`/admin/settings/${key}?default=${encodeURIComponent(carrier.default)}`);
if (data && data.value !== undefined) {
this.shippingSettings[key] = data.value;
}
}
settingsLog.info('Shipping settings loaded:', this.shippingSettings);
} catch (error) {
settingsLog.error('Failed to load shipping settings:', error);
// On error, keep existing defaults
}
},
async saveShippingSettings() {
this.saving = true;
this.error = null;
this.successMessage = null;
try {
// Save each carrier setting using upsert
const carriers = [
{ key: 'carrier_greco_label_url', name: 'Greco' },
{ key: 'carrier_colissimo_label_url', name: 'Colissimo' },
{ key: 'carrier_xpresslogistics_label_url', name: 'XpressLogistics' }
];
for (const carrier of carriers) {
await apiClient.post('/admin/settings/upsert', {
key: carrier.key,
value: this.shippingSettings[carrier.key] || '',
category: 'shipping',
value_type: 'string',
description: `Label URL prefix for ${carrier.name} carrier`
});
}
this.successMessage = 'Shipping settings saved successfully';
// Auto-hide success message after 5 seconds
setTimeout(() => {
this.successMessage = null;
}, 5000);
settingsLog.info('Shipping settings saved:', this.shippingSettings);
} catch (error) {
settingsLog.error('Failed to save shipping settings:', error);
this.error = error.response?.data?.detail || 'Failed to save shipping settings';
} finally {
this.saving = false;
}
},
getShippingLabelUrl(carrier, shipmentNumber) {
// Helper to generate full label URL
const prefix = this.shippingSettings[`carrier_${carrier}_label_url`] || '';
if (!prefix || !shipmentNumber) return null;
return prefix + shipmentNumber;
},
// =====================================================================
// EMAIL SETTINGS
// =====================================================================
async loadEmailSettings() {
try {
const data = await apiClient.get('/admin/settings/email/status');
this.emailSettings = {
provider: data.provider || 'smtp',
from_email: data.from_email || '',
from_name: data.from_name || '',
reply_to: data.reply_to || '',
smtp_host: data.smtp_host || '',
smtp_port: data.smtp_port || 587,
smtp_user: data.smtp_user || '',
mailgun_domain: data.mailgun_domain || '',
aws_region: data.aws_region || '',
debug: data.debug || false,
enabled: data.enabled !== false,
is_configured: data.is_configured || false,
has_db_overrides: data.has_db_overrides || false
};
// Populate edit form with current values
this.populateEmailForm();
settingsLog.info('Email settings loaded:', this.emailSettings);
} catch (error) {
settingsLog.error('Failed to load email settings:', error);
// Use defaults on error
}
},
populateEmailForm() {
// Copy current settings to form (passwords are not loaded from API)
this.emailForm = {
provider: this.emailSettings.provider,
from_email: this.emailSettings.from_email,
from_name: this.emailSettings.from_name,
reply_to: this.emailSettings.reply_to || '',
smtp_host: this.emailSettings.smtp_host || '',
smtp_port: this.emailSettings.smtp_port || 587,
smtp_user: this.emailSettings.smtp_user || '',
smtp_password: '', // Never populated from API
smtp_use_tls: true,
smtp_use_ssl: false,
sendgrid_api_key: '',
mailgun_api_key: '',
mailgun_domain: this.emailSettings.mailgun_domain || '',
aws_access_key_id: '',
aws_secret_access_key: '',
aws_region: this.emailSettings.aws_region || 'eu-west-1',
enabled: this.emailSettings.enabled,
debug: this.emailSettings.debug
};
},
enableEmailEditing() {
this.emailEditMode = true;
this.populateEmailForm();
},
cancelEmailEditing() {
this.emailEditMode = false;
this.populateEmailForm();
},
async saveEmailSettings() {
this.saving = true;
this.error = null;
this.successMessage = null;
try {
// Only send non-empty values to update
const payload = {};
// Always send these core fields
if (this.emailForm.provider) payload.provider = this.emailForm.provider;
if (this.emailForm.from_email) payload.from_email = this.emailForm.from_email;
if (this.emailForm.from_name) payload.from_name = this.emailForm.from_name;
if (this.emailForm.reply_to) payload.reply_to = this.emailForm.reply_to;
payload.enabled = this.emailForm.enabled;
payload.debug = this.emailForm.debug;
// Provider-specific fields
if (this.emailForm.provider === 'smtp') {
if (this.emailForm.smtp_host) payload.smtp_host = this.emailForm.smtp_host;
if (this.emailForm.smtp_port) payload.smtp_port = this.emailForm.smtp_port;
if (this.emailForm.smtp_user) payload.smtp_user = this.emailForm.smtp_user;
if (this.emailForm.smtp_password) payload.smtp_password = this.emailForm.smtp_password;
payload.smtp_use_tls = this.emailForm.smtp_use_tls;
payload.smtp_use_ssl = this.emailForm.smtp_use_ssl;
} else if (this.emailForm.provider === 'sendgrid') {
if (this.emailForm.sendgrid_api_key) payload.sendgrid_api_key = this.emailForm.sendgrid_api_key;
} else if (this.emailForm.provider === 'mailgun') {
if (this.emailForm.mailgun_api_key) payload.mailgun_api_key = this.emailForm.mailgun_api_key;
if (this.emailForm.mailgun_domain) payload.mailgun_domain = this.emailForm.mailgun_domain;
} else if (this.emailForm.provider === 'ses') {
if (this.emailForm.aws_access_key_id) payload.aws_access_key_id = this.emailForm.aws_access_key_id;
if (this.emailForm.aws_secret_access_key) payload.aws_secret_access_key = this.emailForm.aws_secret_access_key;
if (this.emailForm.aws_region) payload.aws_region = this.emailForm.aws_region;
}
const data = await apiClient.put('/admin/settings/email/settings', payload);
this.successMessage = data.message || 'Email settings saved successfully';
this.emailEditMode = false;
// Reload to get updated status
await this.loadEmailSettings();
setTimeout(() => {
this.successMessage = null;
}, 5000);
settingsLog.info('Email settings saved successfully');
} catch (error) {
settingsLog.error('Failed to save email settings:', error);
this.error = error.message || 'Failed to save email settings';
} finally {
this.saving = false;
}
},
async resetEmailSettings() {
if (!confirm('This will reset all email settings to use .env defaults. Continue?')) {
return;
}
this.saving = true;
this.error = null;
this.successMessage = null;
try {
const data = await apiClient.delete('/admin/settings/email/settings');
this.successMessage = data.message || 'Email settings reset to defaults';
this.emailEditMode = false;
// Reload to get .env values
await this.loadEmailSettings();
setTimeout(() => {
this.successMessage = null;
}, 5000);
settingsLog.info('Email settings reset successfully');
} catch (error) {
settingsLog.error('Failed to reset email settings:', error);
this.error = error.message || 'Failed to reset email settings';
} finally {
this.saving = false;
}
},
async sendTestEmail() {
if (!this.testEmailAddress) {
this.testEmailError = 'Please enter a test email address';
return;
}
this.sendingTestEmail = true;
this.testEmailError = null;
this.testEmailSuccess = null;
try {
settingsLog.info('Sending test email to:', this.testEmailAddress);
const data = await apiClient.post('/admin/settings/email/test', {
to_email: this.testEmailAddress
});
settingsLog.info('Test email response:', data);
if (data.success) {
this.testEmailSuccess = `Test email sent to ${this.testEmailAddress}`;
setTimeout(() => {
this.testEmailSuccess = null;
}, 5000);
} else {
settingsLog.error('Test email failed:', data.message);
// Extract the first line of error for cleaner display
let errorMsg = data.message || 'Failed to send test email';
if (errorMsg.includes('\n')) {
errorMsg = errorMsg.split('\n')[0];
}
this.testEmailError = errorMsg;
}
} catch (error) {
settingsLog.error('Failed to send test email (exception):', error);
this.testEmailError = error.message || 'Failed to send test email';
} finally {
this.sendingTestEmail = false;
settingsLog.info('sendingTestEmail set to false, testEmailError:', this.testEmailError);
}
}
};
}
settingsLog.info('Settings module loaded');

View File

@@ -0,0 +1,243 @@
// static/shared/js/vendor-selector.js
/**
* Shared Vendor Selector Module
* =============================
* Provides a reusable Tom Select-based vendor autocomplete component.
*
* Features:
* - Async search with debouncing (150ms)
* - Searches by vendor name and code
* - Dark mode support
* - Caches recent searches
* - Graceful fallback if Tom Select not available
*
* Usage:
* // In Alpine.js component init():
* this.$nextTick(() => {
* this.vendorSelector = initVendorSelector(this.$refs.vendorSelect, {
* onSelect: (vendor) => this.handleVendorSelect(vendor),
* onClear: () => this.handleVendorClear(),
* minChars: 2,
* maxOptions: 50
* });
* });
*
* // To programmatically set a value:
* this.vendorSelector.setValue(vendorId);
*
* // To clear:
* this.vendorSelector.clear();
*/
const vendorSelectorLog = window.LogConfig?.loggers?.vendorSelector ||
window.LogConfig?.createLogger?.('vendorSelector', false) ||
{ info: console.log, warn: console.warn, error: console.error }; // noqa: js-001 - fallback if logger not ready
/**
* Check if Tom Select is available, with retry logic
* @param {Function} callback - Called when Tom Select is available
* @param {number} maxRetries - Maximum retry attempts
* @param {number} retryDelay - Delay between retries in ms
*/
function waitForTomSelect(callback, maxRetries = 20, retryDelay = 100) {
let retries = 0;
function check() {
if (typeof TomSelect !== 'undefined') {
callback();
} else if (retries < maxRetries) {
retries++;
vendorSelectorLog.info(`Waiting for TomSelect... (attempt ${retries}/${maxRetries})`);
setTimeout(check, retryDelay);
} else {
vendorSelectorLog.error('TomSelect not available after maximum retries');
}
}
check();
}
/**
* Initialize a vendor selector on the given element
* @param {HTMLElement} selectElement - The select element to enhance
* @param {Object} options - Configuration options
* @param {Function} options.onSelect - Callback when vendor is selected (receives vendor object)
* @param {Function} options.onClear - Callback when selection is cleared
* @param {number} options.minChars - Minimum characters before search (default: 2)
* @param {number} options.maxOptions - Maximum options to show (default: 50)
* @param {string} options.placeholder - Placeholder text
* @param {string} options.apiEndpoint - API endpoint for search (default: '/admin/vendors')
* @returns {Object} Controller object with setValue() and clear() methods
*/
function initVendorSelector(selectElement, options = {}) {
if (!selectElement) {
vendorSelectorLog.error('Vendor selector element not provided');
return null;
}
const config = {
minChars: options.minChars || 2,
maxOptions: options.maxOptions || 50,
placeholder: options.placeholder || selectElement.getAttribute('placeholder') || 'Search vendor by name or code...',
apiEndpoint: options.apiEndpoint || '/admin/vendors', // Note: apiClient adds /api/v1 prefix
onSelect: options.onSelect || (() => {}),
onClear: options.onClear || (() => {})
};
let tomSelectInstance = null;
// Controller object returned to caller
const controller = {
/**
* Set the selected vendor by ID
* @param {number} vendorId - Vendor ID to select
* @param {Object} vendorData - Optional vendor data to avoid API call
*/
setValue: async function(vendorId, vendorData = null) {
if (!tomSelectInstance) return;
if (vendorData) {
// Add option and set value
tomSelectInstance.addOption({
id: vendorData.id,
name: vendorData.name,
vendor_code: vendorData.vendor_code
});
tomSelectInstance.setValue(vendorData.id, true);
} else if (vendorId) {
// Fetch vendor data and set
try {
const response = await apiClient.get(`${config.apiEndpoint}/${vendorId}`);
tomSelectInstance.addOption({
id: response.id,
name: response.name,
vendor_code: response.vendor_code
});
tomSelectInstance.setValue(response.id, true);
} catch (error) {
vendorSelectorLog.error('Failed to load vendor:', error);
}
}
},
/**
* Clear the selection
*/
clear: function() {
if (tomSelectInstance) {
tomSelectInstance.clear();
}
},
/**
* Get the Tom Select instance
*/
getInstance: function() {
return tomSelectInstance;
},
/**
* Destroy the Tom Select instance
*/
destroy: function() {
if (tomSelectInstance) {
tomSelectInstance.destroy();
tomSelectInstance = null;
}
}
};
// Initialize Tom Select when available
waitForTomSelect(() => {
vendorSelectorLog.info('Initializing vendor selector');
tomSelectInstance = new TomSelect(selectElement, {
valueField: 'id',
labelField: 'name',
searchField: ['name', 'vendor_code'],
maxOptions: config.maxOptions,
placeholder: config.placeholder,
// Async search with debouncing
load: async function(query, callback) {
if (query.length < config.minChars) {
callback([]);
return;
}
try {
const response = await apiClient.get(
`${config.apiEndpoint}?search=${encodeURIComponent(query)}&limit=${config.maxOptions}`
);
const vendors = (response.vendors || []).map(v => ({
id: v.id,
name: v.name,
vendor_code: v.vendor_code
}));
vendorSelectorLog.info(`Found ${vendors.length} vendors for "${query}"`);
callback(vendors);
} catch (error) {
vendorSelectorLog.error('Vendor search failed:', error);
callback([]);
}
},
// Custom rendering
render: {
option: function(data, escape) {
return `<div class="flex justify-between items-center py-1">
<span class="font-medium">${escape(data.name)}</span>
<span class="text-xs text-gray-400 dark:text-gray-500 ml-2 font-mono">${escape(data.vendor_code)}</span>
</div>`;
},
item: function(data, escape) {
return `<div class="flex items-center gap-2">
<span>${escape(data.name)}</span>
<span class="text-xs text-gray-400 font-mono">(${escape(data.vendor_code)})</span>
</div>`;
},
no_results: function() {
return '<div class="no-results py-2 px-3 text-gray-500 dark:text-gray-400">No vendors found</div>';
},
loading: function() {
return '<div class="loading py-2 px-3 text-gray-500 dark:text-gray-400">Searching...</div>';
}
},
// Event handlers
onChange: function(value) {
if (value) {
const selectedOption = this.options[value];
if (selectedOption) {
vendorSelectorLog.info('Vendor selected:', selectedOption);
config.onSelect({
id: parseInt(value),
name: selectedOption.name,
vendor_code: selectedOption.vendor_code
});
}
} else {
vendorSelectorLog.info('Vendor selection cleared');
config.onClear();
}
},
// Performance settings
loadThrottle: 150, // Debounce search requests
closeAfterSelect: true,
hideSelected: false,
persist: true, // Cache options
createOnBlur: false,
create: false
});
vendorSelectorLog.info('Vendor selector initialized');
});
return controller;
}
// Export to window for global access
window.initVendorSelector = initVendorSelector;

View File

@@ -0,0 +1,295 @@
// static/storefront/js/storefront-layout.js
/**
* Shop Layout Component
* Provides base functionality for vendor shop pages
* Works with vendor-specific themes
*/
const shopLog = {
info: (...args) => console.info('🛒 [SHOP]', ...args),
warn: (...args) => console.warn('⚠️ [SHOP]', ...args),
error: (...args) => console.error('❌ [SHOP]', ...args),
debug: (...args) => console.log('🔍 [SHOP]', ...args)
};
/**
* Shop Layout Data
* Base Alpine.js component for shop pages
*/
function shopLayoutData() {
return {
// Theme state
dark: localStorage.getItem('shop-theme') === 'dark',
// UI state
mobileMenuOpen: false,
searchOpen: false,
loading: false,
cartCount: 0,
// Cart state
cart: [],
sessionId: null,
// Initialize
init() {
shopLog.info('Shop layout initializing...');
// Get or create session ID
this.sessionId = this.getOrCreateSessionId();
shopLog.debug('Session ID:', this.sessionId);
// Load cart from localStorage
this.loadCart();
// Listen for cart updates
window.addEventListener('cart-updated', () => {
this.loadCart();
});
shopLog.info('Shop layout initialized');
},
// Get or create session ID
getOrCreateSessionId() {
let sessionId = localStorage.getItem('cart_session_id');
if (!sessionId) {
sessionId = 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
localStorage.setItem('cart_session_id', sessionId);
shopLog.info('Created new session ID:', sessionId);
}
return sessionId;
},
// Theme management
toggleTheme() {
this.dark = !this.dark;
localStorage.setItem('shop-theme', this.dark ? 'dark' : 'light');
shopLog.debug('Theme toggled:', this.dark ? 'dark' : 'light');
},
// Mobile menu
toggleMobileMenu() {
this.mobileMenuOpen = !this.mobileMenuOpen;
if (this.mobileMenuOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
},
closeMobileMenu() {
this.mobileMenuOpen = false;
document.body.style.overflow = '';
},
// Search
openSearch() {
this.searchOpen = true;
shopLog.debug('Search opened');
// Focus search input after a short delay
setTimeout(() => {
const input = document.querySelector('#search-input');
if (input) input.focus();
}, 100);
},
closeSearch() {
this.searchOpen = false;
},
// Cart management
loadCart() {
try {
const cartData = localStorage.getItem('shop-cart');
if (cartData) {
this.cart = JSON.parse(cartData);
this.cartCount = this.cart.reduce((sum, item) => sum + item.quantity, 0);
}
} catch (error) {
shopLog.error('Failed to load cart:', error);
this.cart = [];
this.cartCount = 0;
}
},
addToCart(product, quantity = 1) {
shopLog.info('Adding to cart:', product.name, 'x', quantity);
// Find existing item
const existingIndex = this.cart.findIndex(item => item.id === product.id);
if (existingIndex !== -1) {
// Update quantity
this.cart[existingIndex].quantity += quantity;
} else {
// Add new item
this.cart.push({
id: product.id,
name: product.name,
price: product.price,
image: product.image,
quantity: quantity
});
}
// Save and update
this.saveCart();
this.showToast(`${product.name} added to cart`, 'success');
},
updateCartItem(productId, quantity) {
const index = this.cart.findIndex(item => item.id === productId);
if (index !== -1) {
if (quantity <= 0) {
this.cart.splice(index, 1);
} else {
this.cart[index].quantity = quantity;
}
this.saveCart();
}
},
removeFromCart(productId) {
this.cart = this.cart.filter(item => item.id !== productId);
this.saveCart();
this.showToast('Item removed from cart', 'info');
},
clearCart() {
this.cart = [];
this.saveCart();
this.showToast('Cart cleared', 'info');
},
saveCart() {
try {
localStorage.setItem('shop-cart', JSON.stringify(this.cart));
this.cartCount = this.cart.reduce((sum, item) => sum + item.quantity, 0);
// Dispatch custom event
window.dispatchEvent(new CustomEvent('cart-updated'));
shopLog.debug('Cart saved:', this.cart.length, 'items');
} catch (error) {
shopLog.error('Failed to save cart:', error);
}
},
// Get cart total
get cartTotal() {
return this.cart.reduce((sum, item) => sum + (item.price * item.quantity), 0);
},
// Toast notifications
showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
if (!container) return;
const toast = document.createElement('div');
toast.className = `toast toast-${type} transform transition-all duration-300 mb-2`;
// Color based on type
const colors = {
success: 'bg-green-500',
error: 'bg-red-500',
warning: 'bg-yellow-500',
info: 'bg-blue-500'
};
// noqa: SEC-015 - message is application-controlled
toast.innerHTML = `
<div class="${colors[type]} text-white px-6 py-3 rounded-lg shadow-lg flex items-center space-x-3">
<span>${message}</span>
<button onclick="this.parentElement.parentElement.remove()"
class="ml-4 hover:opacity-75">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
`;
container.appendChild(toast);
// Auto-remove after 3 seconds
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 300);
}, 3000);
},
// Format currency using configured locale
formatPrice(amount) {
if (!amount && amount !== 0) return '';
const locale = window.SHOP_CONFIG?.locale || 'fr-LU';
const currency = window.SHOP_CONFIG?.currency || 'EUR';
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency
}).format(amount);
},
// Format date
formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
};
}
// Make available globally
window.shopLayoutData = shopLayoutData;
/**
* Language Selector Component
* Alpine.js component for language switching
*/
function languageSelector(currentLang, enabledLanguages) {
return {
isLangOpen: false,
currentLang: currentLang || 'fr',
languages: enabledLanguages || ['fr', 'de', 'en'],
languageNames: {
'en': 'English',
'fr': 'Français',
'de': 'Deutsch',
'lb': 'Lëtzebuergesch'
},
languageFlags: {
'en': 'gb',
'fr': 'fr',
'de': 'de',
'lb': 'lu'
},
async setLanguage(lang) {
if (lang === this.currentLang) {
this.isLangOpen = false;
return;
}
try {
const response = await fetch('/api/v1/language/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ language: lang })
});
if (response.ok) {
this.currentLang = lang;
window.location.reload();
}
} catch (error) {
console.error('Failed to set language:', error);
}
this.isLangOpen = false;
}
};
}
window.languageSelector = languageSelector;
shopLog.info('Shop layout module loaded');

View File

@@ -0,0 +1,129 @@
// app/static/vendor/js/dashboard.js
/**
* Vendor dashboard page logic
*/
// ✅ Use centralized logger (with safe fallback)
const vendorDashLog = window.LogConfig.loggers.dashboard ||
window.LogConfig.createLogger('dashboard', false);
vendorDashLog.info('Loading...');
vendorDashLog.info('[VENDOR DASHBOARD] data function exists?', typeof data);
function vendorDashboard() {
vendorDashLog.info('[VENDOR DASHBOARD] vendorDashboard() called');
vendorDashLog.info('[VENDOR DASHBOARD] data function exists inside?', typeof data);
return {
// ✅ Inherit base layout state (includes vendorCode, dark mode, menu states)
...data(),
// ✅ Set page identifier
currentPage: 'dashboard',
loading: false,
error: '',
stats: {
products_count: 0,
orders_count: 0,
customers_count: 0,
revenue: 0
},
recentOrders: [],
recentProducts: [],
async init() {
// Guard against multiple initialization
if (window._vendorDashboardInitialized) {
return;
}
window._vendorDashboardInitialized = true;
try {
// IMPORTANT: Call parent init first to set vendorCode from URL
const parentInit = data().init;
if (parentInit) {
await parentInit.call(this);
}
await this.loadDashboardData();
} catch (error) {
vendorDashLog.error('Failed to initialize dashboard:', error);
}
},
async loadDashboardData() {
this.loading = true;
this.error = '';
try {
// Load stats
// NOTE: apiClient prepends /api/v1, and vendor context middleware handles vendor detection
// So we just call /vendor/dashboard/stats → becomes /api/v1/vendor/dashboard/stats
const statsResponse = await apiClient.get(
`/vendor/dashboard/stats`
);
// Map API response to stats (similar to admin dashboard pattern)
this.stats = {
products_count: statsResponse.products?.total || 0,
orders_count: statsResponse.orders?.total || 0,
customers_count: statsResponse.customers?.total || 0,
revenue: statsResponse.revenue?.total || 0
};
// Load recent orders
const ordersResponse = await apiClient.get(
`/vendor/orders?limit=5&sort=created_at:desc`
);
this.recentOrders = ordersResponse.items || [];
// Load recent products
const productsResponse = await apiClient.get(
`/vendor/products?limit=5&sort=created_at:desc`
);
this.recentProducts = productsResponse.items || [];
vendorDashLog.info('Dashboard data loaded', {
stats: this.stats,
orders: this.recentOrders.length,
products: this.recentProducts.length
});
} catch (error) {
vendorDashLog.error('Failed to load dashboard data', error);
this.error = 'Failed to load dashboard data. Please try refreshing the page.';
} finally {
this.loading = false;
}
},
async refresh() {
try {
await this.loadDashboardData();
} catch (error) {
vendorDashLog.error('Failed to refresh dashboard:', error);
}
},
formatCurrency(amount) {
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
const currency = window.VENDOR_CONFIG?.currency || 'EUR';
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency
}).format(amount || 0);
},
formatDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
return date.toLocaleDateString(locale, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
};
}

View File

@@ -0,0 +1,270 @@
// app/static/vendor/js/init-alpine.js
/**
* Alpine.js initialization for vendor pages
* Provides common data and methods for all vendor pages
*/
// ✅ Use centralized logger
const vendorLog = window.LogConfig.log;
console.log('[VENDOR INIT-ALPINE] Loading...');
// Sidebar section state persistence
const VENDOR_SIDEBAR_STORAGE_KEY = 'vendor_sidebar_sections';
function getVendorSidebarSectionsFromStorage() {
try {
const stored = localStorage.getItem(VENDOR_SIDEBAR_STORAGE_KEY);
if (stored) {
return JSON.parse(stored);
}
} catch (e) {
console.warn('[VENDOR INIT-ALPINE] Failed to load sidebar state from localStorage:', e);
}
// Default: all sections open
return {
products: true,
sales: true,
customers: true,
shop: true,
account: true
};
}
function saveVendorSidebarSectionsToStorage(sections) {
try {
localStorage.setItem(VENDOR_SIDEBAR_STORAGE_KEY, JSON.stringify(sections));
} catch (e) {
console.warn('[VENDOR INIT-ALPINE] Failed to save sidebar state to localStorage:', e);
}
}
function data() {
console.log('[VENDOR INIT-ALPINE] data() function called');
return {
dark: false,
isSideMenuOpen: false,
isNotificationsMenuOpen: false,
isProfileMenuOpen: false,
currentPage: '',
currentUser: {},
vendor: null,
vendorCode: null,
// Sidebar collapsible sections state
openSections: getVendorSidebarSectionsFromStorage(),
init() {
// Set current page from URL
const path = window.location.pathname;
const segments = path.split('/').filter(Boolean);
this.currentPage = segments[segments.length - 1] || 'dashboard';
// Get vendor code from URL
if (segments[0] === 'vendor' && segments[1]) {
this.vendorCode = segments[1];
}
// Load user from localStorage
const user = localStorage.getItem('currentUser');
if (user) {
this.currentUser = JSON.parse(user);
}
// Load theme preference
const theme = localStorage.getItem('theme');
if (theme === 'dark') {
this.dark = true;
}
// Load vendor info
this.loadVendorInfo();
// Save last visited page (for redirect after login)
// Exclude login, logout, onboarding, error pages
if (!path.includes('/login') &&
!path.includes('/logout') &&
!path.includes('/onboarding') &&
!path.includes('/errors/')) {
try {
localStorage.setItem('vendor_last_visited_page', path);
} catch (e) {
// Ignore storage errors
}
}
},
async loadVendorInfo() {
if (!this.vendorCode) return;
try {
// apiClient prepends /api/v1, so /vendor/info/{code} → /api/v1/vendor/info/{code}
const response = await apiClient.get(`/vendor/info/${this.vendorCode}`);
this.vendor = response;
vendorLog.debug('Vendor info loaded', this.vendor);
} catch (error) {
vendorLog.error('Failed to load vendor info', error);
}
},
toggleSideMenu() {
this.isSideMenuOpen = !this.isSideMenuOpen;
},
closeSideMenu() {
this.isSideMenuOpen = false;
},
toggleNotificationsMenu() {
this.isNotificationsMenuOpen = !this.isNotificationsMenuOpen;
if (this.isNotificationsMenuOpen) {
this.isProfileMenuOpen = false;
}
},
closeNotificationsMenu() {
this.isNotificationsMenuOpen = false;
},
toggleProfileMenu() {
this.isProfileMenuOpen = !this.isProfileMenuOpen;
if (this.isProfileMenuOpen) {
this.isNotificationsMenuOpen = false;
}
},
closeProfileMenu() {
this.isProfileMenuOpen = false;
},
toggleTheme() {
this.dark = !this.dark;
localStorage.setItem('theme', this.dark ? 'dark' : 'light');
},
// Sidebar section toggle with persistence
toggleSection(section) {
this.openSections[section] = !this.openSections[section];
saveVendorSidebarSectionsToStorage(this.openSections);
},
async handleLogout() {
console.log('🚪 Logging out vendor user...');
try {
// Call logout API
await apiClient.post('/vendor/auth/logout');
console.log('✅ Logout API called successfully');
} catch (error) {
console.error('⚠️ Logout API error (continuing anyway):', error);
} finally {
// Clear vendor tokens only (not admin or customer tokens)
// Keep vendor_last_visited_page so user returns to same page after login
console.log('🧹 Clearing vendor tokens...');
localStorage.removeItem('vendor_token');
localStorage.removeItem('vendor_user');
localStorage.removeItem('currentUser');
localStorage.removeItem('vendorCode');
// Note: Do NOT use localStorage.clear() - it would clear admin/customer tokens too
console.log('🔄 Redirecting to login...');
window.location.href = `/vendor/${this.vendorCode}/login`;
}
}
};
}
/**
* Language Selector Component
* Alpine.js component for language switching in vendor dashboard
*/
function languageSelector(currentLang, enabledLanguages) {
return {
isLangOpen: false,
currentLang: currentLang || 'fr',
languages: enabledLanguages || ['en', 'fr', 'de', 'lb'],
languageNames: {
'en': 'English',
'fr': 'Français',
'de': 'Deutsch',
'lb': 'Lëtzebuergesch'
},
languageFlags: {
'en': 'gb',
'fr': 'fr',
'de': 'de',
'lb': 'lu'
},
async setLanguage(lang) {
if (lang === this.currentLang) {
this.isLangOpen = false;
return;
}
try {
const response = await fetch('/api/v1/language/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ language: lang })
});
if (response.ok) {
this.currentLang = lang;
window.location.reload();
}
} catch (error) {
console.error('Failed to set language:', error);
}
this.isLangOpen = false;
}
};
}
window.languageSelector = languageSelector;
/**
* Email Settings Warning Component
* Shows warning banner when vendor email settings are not configured
*
* Usage in template:
* <div x-data="emailSettingsWarning()" x-show="showWarning">...</div>
*/
function emailSettingsWarning() {
return {
showWarning: false,
loading: true,
vendorCode: null,
async init() {
// Get vendor code from URL
const path = window.location.pathname;
const segments = path.split('/').filter(Boolean);
if (segments[0] === 'vendor' && segments[1]) {
this.vendorCode = segments[1];
}
// Skip if we're on the settings page (to avoid showing banner on config page)
if (path.includes('/settings')) {
this.loading = false;
return;
}
// Check email settings status
await this.checkEmailStatus();
},
async checkEmailStatus() {
try {
const response = await apiClient.get('/vendor/email-settings/status');
// Show warning if not configured
this.showWarning = !response.is_configured;
} catch (error) {
// Don't show warning on error (might be 401, etc.)
console.debug('[EmailWarning] Failed to check email status:', error);
this.showWarning = false;
} finally {
this.loading = false;
}
}
};
}
window.emailSettingsWarning = emailSettingsWarning;

View File

@@ -0,0 +1,141 @@
{# app/templates/admin/dashboard.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
{% block title %}Dashboard{% endblock %}
{% block alpine_data %}adminDashboard(){% endblock %}
{% block content %}
{{ page_header('Dashboard', action_label='Refresh', action_onclick='refresh()', action_icon='refresh') }}
{{ loading_state('Loading dashboard...') }}
{{ error_state('Error loading dashboard') }}
<!-- Stats Cards -->
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<!-- Card: Total Vendors -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
<span x-html="$icon('user-group', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Total Vendors
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.totalVendors">
0
</p>
</div>
</div>
<!-- Card: Active Users -->
<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('users', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Active Users
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.activeUsers">
0
</p>
</div>
</div>
<!-- Card: Verified Vendors -->
<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('badge-check', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Verified Vendors
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.verifiedVendors">
0
</p>
</div>
</div>
<!-- Card: Import Jobs -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-teal-500 bg-teal-100 rounded-full dark:text-teal-100 dark:bg-teal-500">
<span x-html="$icon('download', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Import Jobs
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.importJobs">
0
</p>
</div>
</div>
</div>
<!-- Recent Vendors Table -->
<div x-show="!loading">
{% call table_wrapper() %}
{{ table_header(['Vendor', 'Status', 'Created', 'Actions']) }}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="recentVendors.length === 0">
<tr>
<td colspan="4" class="px-4 py-8 text-center text-gray-600 dark:text-gray-400">
<div class="flex flex-col items-center">
<span x-html="$icon('user-group', 'w-12 h-12 mb-2 text-gray-300')"></span>
<p>No vendors yet.</p>
</div>
</td>
</tr>
</template>
<template x-for="vendor in recentVendors" :key="vendor.vendor_code">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<td class="px-4 py-3">
<div class="flex items-center text-sm">
<div class="relative hidden w-8 h-8 mr-3 rounded-full md:block">
<div class="absolute inset-0 rounded-full bg-purple-100 dark:bg-purple-600 flex items-center justify-center">
<span class="text-xs font-semibold text-purple-600 dark:text-purple-100" x-text="vendor.name?.charAt(0).toUpperCase() || '?'"></span>
</div>
</div>
<div>
<p class="font-semibold" x-text="vendor.name"></p>
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="vendor.vendor_code"></p>
</div>
</div>
</td>
<td class="px-4 py-3 text-xs">
<span class="inline-flex items-center px-2 py-1 font-semibold leading-tight rounded-full"
:class="vendor.is_verified ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-orange-700 bg-orange-100 dark:text-white dark:bg-orange-600'">
<span x-show="vendor.is_verified" x-html="$icon('badge-check', 'w-3 h-3 mr-1')"></span>
<span x-text="vendor.is_verified ? 'Verified' : 'Pending'"></span>
</span>
</td>
<td class="px-4 py-3 text-sm" x-text="formatDate(vendor.created_at)">
</td>
<td class="px-4 py-3">
<div class="flex items-center space-x-2 text-sm">
<button
@click="viewVendor(vendor.vendor_code)"
class="flex items-center justify-center p-2 text-purple-600 rounded-lg hover:bg-purple-50 dark:text-gray-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="View vendor"
>
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</button>
</div>
</td>
</tr>
</template>
</tbody>
{% endcall %}
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('core_static', path='admin/js/dashboard.js') }}"></script>
{% endblock %}

View File

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

View File

@@ -0,0 +1,782 @@
{# app/templates/admin/settings.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/alerts.html' import alert_dynamic, error_state %}
{% from 'shared/macros/headers.html' import page_header_refresh %}
{% from 'shared/macros/tabs.html' import tabs_nav, tab_button %}
{% from 'shared/macros/inputs.html' import number_stepper %}
{% block title %}Platform Settings{% endblock %}
{% block alpine_data %}adminSettings(){% endblock %}
{% block content %}
{{ page_header_refresh('Platform Settings') }}
{{ alert_dynamic(type='success', title='Success', message_var='successMessage', show_condition='successMessage') }}
{{ error_state('Error', show_condition='error') }}
<!-- Settings Categories Tabs -->
{% call tabs_nav() %}
{{ tab_button('display', 'Display', icon='view-grid') }}
{{ tab_button('logging', 'Logging', icon='document-text') }}
{{ tab_button('email', 'Email', icon='mail') }}
{{ tab_button('shipping', 'Shipping', icon='truck') }}
{{ tab_button('system', 'System', icon='cog') }}
{{ tab_button('security', 'Security', icon='shield-check') }}
{{ tab_button('notifications', 'Notifications', icon='bell') }}
{% endcall %}
<!-- Display Settings Tab -->
<div x-show="activeTab === 'display'" x-transition>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-4">
Display Configuration
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
Configure how data is displayed across the admin interface.
</p>
<!-- Rows Per Page Setting -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Default Rows Per Page
</label>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3">
Set the default number of rows shown in tables across the admin interface.
</p>
<div class="flex items-center gap-3">
<template x-for="option in [10, 20, 50, 100]" :key="option">
<button
@click="displaySettings.rows_per_page = option"
class="px-4 py-2 text-sm font-medium rounded-lg border transition-colors"
:class="displaySettings.rows_per_page === option
? 'bg-purple-600 text-white border-purple-600 dark:bg-purple-500 dark:border-purple-500'
: 'text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'"
x-text="option"
></button>
</template>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
This setting applies to: Orders, Products, Customers, Inventory, and other tables.
</p>
</div>
<!-- Info Box -->
<div class="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<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 flex-shrink-0')"></span>
<div class="text-sm text-blue-800 dark:text-blue-200">
<p class="font-medium mb-1">When does this take effect?</p>
<p>Changes to the rows per page setting will apply immediately to all admin tables when refreshed.</p>
</div>
</div>
</div>
<!-- Save Button -->
<div class="flex items-center justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
<button
@click="saveDisplaySettings()"
:disabled="saving"
class="px-6 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="!saving">Save Display Settings</span>
<span x-show="saving">Saving...</span>
</button>
</div>
</div>
</div>
<!-- Logging Settings Tab -->
<div x-show="activeTab === 'logging'" x-transition>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-4">
Logging Configuration
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
Configure application logging behavior, file rotation, and retention policies.
</p>
<!-- Log Level -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Log Level
</label>
<select
x-model="logSettings.log_level"
class="block w-full md:w-1/2 px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
>
<option value="DEBUG">DEBUG - Detailed information for diagnosing problems</option>
<option value="INFO">INFO - General informational messages</option>
<option value="WARNING">WARNING - Warning messages</option>
<option value="ERROR">ERROR - Error messages</option>
<option value="CRITICAL">CRITICAL - Critical errors only</option>
</select>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Changes take effect immediately without restart.
</p>
</div>
<!-- File Rotation Settings -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Max File Size (MB)
</label>
{{ number_stepper(model='logSettings.log_file_max_size_mb', min=1, max=1000, step=10, label='Max File Size') }}
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Log file will rotate when it reaches this size.
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Backup File Count
</label>
{{ number_stepper(model='logSettings.log_file_backup_count', min=0, max=50, step=1, label='Backup File Count') }}
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Number of rotated backup files to keep.
</p>
</div>
</div>
<!-- Database Retention -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Database Log Retention (Days)
</label>
{{ number_stepper(model='logSettings.db_log_retention_days', min=1, max=365, step=7, label='Retention Days') }}
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Logs older than this will be automatically deleted from database.
</p>
</div>
<!-- Logging Toggles -->
<div class="space-y-4 mb-6">
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div>
<p class="font-medium text-gray-700 dark:text-gray-200">File Logging</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Write logs to rotating files on disk</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" x-model="logSettings.file_logging_enabled" class="sr-only peer">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-300 dark:peer-focus:ring-purple-800 rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-purple-600"></div>
</label>
</div>
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div>
<p class="font-medium text-gray-700 dark:text-gray-200">Database Logging</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Store WARNING/ERROR/CRITICAL logs in database for searching</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" x-model="logSettings.db_logging_enabled" class="sr-only peer">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-300 dark:peer-focus:ring-purple-800 rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-purple-600"></div>
</label>
</div>
</div>
<!-- Save Button -->
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<p class="text-sm text-gray-600 dark:text-gray-400">
<span x-html="$icon('information-circle', 'inline w-4 h-4 mr-1')"></span>
File rotation settings require application restart to take effect.
</p>
<button
@click="saveLogSettings()"
:disabled="saving"
class="px-6 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="!saving">Save Logging Settings</span>
<span x-show="saving">Saving...</span>
</button>
</div>
</div>
<!-- Quick Actions -->
<div class="mt-8">
<div class="mb-8 p-6 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<h4 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Quick Actions
</h4>
<div class="flex flex-wrap gap-3">
<a
href="/admin/logs"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple"
>
<span x-html="$icon('document-text', 'w-4 h-4 mr-2')"></span>
View Logs
</a>
<button
@click="cleanupOldLogs()"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:shadow-outline-gray"
>
<span x-html="$icon('delete', 'w-4 h-4 mr-2')"></span>
Cleanup Old Logs
</button>
</div>
</div>
</div>
</div>
<!-- Email Settings Tab -->
<div x-show="activeTab === 'email'" x-transition>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
Platform Email Configuration
</h3>
<!-- Edit/Cancel buttons -->
<div class="flex items-center gap-2">
<template x-if="!emailEditMode">
<button
@click="enableEmailEditing()"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
>
<span x-html="$icon('pencil', 'w-4 h-4 inline mr-1')"></span>
Edit Settings
</button>
</template>
<template x-if="emailEditMode">
<button
@click="cancelEmailEditing()"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
>
Cancel
</button>
</template>
</div>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
Configure the platform's email settings for system emails (billing, subscriptions, admin notifications).
Vendor emails use each vendor's own email settings.
</p>
<!-- Current Status -->
<div class="mb-6 p-4 rounded-lg" :class="emailSettings.is_configured ? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800' : 'bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800'">
<div class="flex items-center justify-between">
<div class="flex items-center">
<template x-if="emailSettings.is_configured">
<div class="flex items-center">
<span x-html="$icon('check-circle', 'w-5 h-5 text-green-600 dark:text-green-400 mr-2')"></span>
<span class="text-green-800 dark:text-green-300 font-medium">Email configured</span>
</div>
</template>
<template x-if="!emailSettings.is_configured">
<div class="flex items-center">
<span x-html="$icon('exclamation-triangle', 'w-5 h-5 text-yellow-600 dark:text-yellow-400 mr-2')"></span>
<span class="text-yellow-800 dark:text-yellow-300 font-medium">Email not fully configured</span>
</div>
</template>
</div>
<template x-if="emailSettings.has_db_overrides">
<span class="px-2 py-1 text-xs font-medium bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200 rounded">
Database overrides active
</span>
</template>
</div>
</div>
<!-- ===== READ-ONLY VIEW ===== -->
<template x-if="!emailEditMode">
<div>
<!-- Provider Selection (Read-only) -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Email Provider
</label>
<div class="grid grid-cols-2 md:grid-cols-5 gap-3">
<template x-for="provider in ['smtp', 'sendgrid', 'mailgun', 'ses', 'debug']" :key="provider">
<div
class="p-3 border-2 rounded-lg text-center"
:class="emailSettings.provider === provider
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/30'
: 'border-gray-200 dark:border-gray-600'"
>
<div class="text-sm font-medium text-gray-700 dark:text-gray-300 capitalize" x-text="provider === 'ses' ? 'Amazon SES' : provider"></div>
<template x-if="emailSettings.provider === provider">
<span x-html="$icon('check-circle', 'w-4 h-4 text-purple-600 dark:text-purple-400 mx-auto mt-1')"></span>
</template>
</div>
</template>
</div>
</div>
<!-- Current Settings (Read-only) -->
<div class="space-y-4">
<h4 class="text-md font-medium text-gray-700 dark:text-gray-300 border-b dark:border-gray-600 pb-2">
Current Configuration
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">From Email</label>
<div class="px-3 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg text-sm text-gray-700 dark:text-gray-300" x-text="emailSettings.from_email || 'Not configured'"></div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">From Name</label>
<div class="px-3 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg text-sm text-gray-700 dark:text-gray-300" x-text="emailSettings.from_name || 'Not configured'"></div>
</div>
<template x-if="emailSettings.provider === 'smtp'">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SMTP Host</label>
<div class="px-3 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg text-sm text-gray-700 dark:text-gray-300" x-text="emailSettings.smtp_host || 'Not configured'"></div>
</div>
</template>
<template x-if="emailSettings.provider === 'smtp'">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SMTP Port</label>
<div class="px-3 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg text-sm text-gray-700 dark:text-gray-300" x-text="emailSettings.smtp_port || 'Not configured'"></div>
</div>
</template>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Debug Mode</label>
<div class="px-3 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg text-sm" :class="emailSettings.debug ? 'text-yellow-600 dark:text-yellow-400' : 'text-gray-700 dark:text-gray-300'" x-text="emailSettings.debug ? 'Enabled (emails logged, not sent)' : 'Disabled'"></div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email Sending</label>
<div class="px-3 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg text-sm" :class="emailSettings.enabled ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'" x-text="emailSettings.enabled ? 'Enabled' : 'Disabled'"></div>
</div>
</div>
</div>
<!-- Info Box -->
<div class="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<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 flex-shrink-0')"></span>
<div class="text-sm text-blue-800 dark:text-blue-200">
<p class="font-medium mb-1">Configuration Priority</p>
<p>Settings can be configured via environment variables (.env) or overridden in the database using the Edit button above. Database settings take priority over .env values.</p>
</div>
</div>
</div>
<!-- Reset to .env button (only show if DB overrides exist) -->
<template x-if="emailSettings.has_db_overrides">
<div class="mt-4">
<button
@click="resetEmailSettings()"
:disabled="saving"
class="px-4 py-2 text-sm font-medium text-red-600 dark:text-red-400 bg-white dark:bg-gray-700 border border-red-300 dark:border-red-600 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50"
>
<span x-html="$icon('refresh', 'w-4 h-4 inline mr-1')"></span>
Reset to .env Defaults
</button>
</div>
</template>
</div>
</template>
<!-- ===== EDIT MODE ===== -->
<template x-if="emailEditMode">
<div>
<!-- Provider Selection (Editable) -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Email Provider
</label>
<select
x-model="emailForm.provider"
class="block w-full md:w-1/2 px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
>
<option value="smtp">SMTP</option>
<option value="sendgrid">SendGrid</option>
<option value="mailgun">Mailgun</option>
<option value="ses">Amazon SES</option>
</select>
</div>
<!-- Common Settings -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">From Email *</label>
<input
type="email"
x-model="emailForm.from_email"
placeholder="noreply@yourplatform.com"
class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">From Name *</label>
<input
type="text"
x-model="emailForm.from_name"
placeholder="Your Platform"
class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Reply-To Email</label>
<input
type="email"
x-model="emailForm.reply_to"
placeholder="support@yourplatform.com"
class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
/>
</div>
</div>
<!-- SMTP Settings -->
<template x-if="emailForm.provider === 'smtp'">
<div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg mb-6">
<h4 class="text-md font-medium text-gray-700 dark:text-gray-300 mb-4">SMTP Settings</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SMTP Host</label>
<input type="text" x-model="emailForm.smtp_host" placeholder="smtp.example.com" class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SMTP Port</label>
<input type="number" x-model="emailForm.smtp_port" placeholder="587" class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SMTP Username</label>
<input type="text" x-model="emailForm.smtp_user" placeholder="username" class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SMTP Password</label>
<input type="password" x-model="emailForm.smtp_password" placeholder="Enter new password" class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600" />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Leave blank to keep existing</p>
</div>
<div class="flex items-center gap-4">
<label class="flex items-center">
<input type="checkbox" x-model="emailForm.smtp_use_tls" class="rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Use TLS</span>
</label>
<label class="flex items-center">
<input type="checkbox" x-model="emailForm.smtp_use_ssl" class="rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Use SSL</span>
</label>
</div>
</div>
</div>
</template>
<!-- SendGrid Settings -->
<template x-if="emailForm.provider === 'sendgrid'">
<div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg mb-6">
<h4 class="text-md font-medium text-gray-700 dark:text-gray-300 mb-4">SendGrid Settings</h4>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">API Key</label>
<input type="password" x-model="emailForm.sendgrid_api_key" placeholder="Enter API key" class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600" />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Leave blank to keep existing</p>
</div>
</div>
</template>
<!-- Mailgun Settings -->
<template x-if="emailForm.provider === 'mailgun'">
<div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg mb-6">
<h4 class="text-md font-medium text-gray-700 dark:text-gray-300 mb-4">Mailgun Settings</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">API Key</label>
<input type="password" x-model="emailForm.mailgun_api_key" placeholder="Enter API key" class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600" />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Leave blank to keep existing</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Domain</label>
<input type="text" x-model="emailForm.mailgun_domain" placeholder="mg.yourdomain.com" class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600" />
</div>
</div>
</div>
</template>
<!-- SES Settings -->
<template x-if="emailForm.provider === 'ses'">
<div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg mb-6">
<h4 class="text-md font-medium text-gray-700 dark:text-gray-300 mb-4">Amazon SES Settings</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Access Key ID</label>
<input type="password" x-model="emailForm.aws_access_key_id" placeholder="Enter access key" class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Secret Access Key</label>
<input type="password" x-model="emailForm.aws_secret_access_key" placeholder="Enter secret key" class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Region</label>
<select x-model="emailForm.aws_region" class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600">
<option value="us-east-1">US East (N. Virginia)</option>
<option value="us-west-2">US West (Oregon)</option>
<option value="eu-west-1">EU (Ireland)</option>
<option value="eu-central-1">EU (Frankfurt)</option>
<option value="ap-southeast-1">Asia Pacific (Singapore)</option>
</select>
</div>
</div>
</div>
</template>
<!-- Behavior Settings -->
<div class="flex items-center gap-6 mb-6">
<label class="flex items-center">
<input type="checkbox" x-model="emailForm.enabled" class="rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Enable email sending</span>
</label>
<label class="flex items-center">
<input type="checkbox" x-model="emailForm.debug" class="rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Debug mode (log only, don't send)</span>
</label>
</div>
<!-- Save Button -->
<div class="flex items-center justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
<button
@click="saveEmailSettings()"
:disabled="saving"
class="px-6 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="!saving">Save Email Settings</span>
<span x-show="saving">Saving...</span>
</button>
</div>
</div>
</template>
<!-- Test Email -->
<div class="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
<h4 class="text-md font-medium text-gray-700 dark:text-gray-300 mb-4">Send Test Email</h4>
<div class="flex items-center gap-3">
<input
type="email"
x-model="testEmailAddress"
placeholder="test@example.com"
class="flex-1 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
/>
<button
@click="sendTestEmail()"
:disabled="!testEmailAddress || sendingTestEmail"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="!sendingTestEmail">Send Test</span>
<span x-show="sendingTestEmail">Sending...</span>
</button>
</div>
<!-- Local feedback for test email -->
<template x-if="testEmailError">
<div class="mt-3 p-3 bg-red-100 border-2 border-red-500 rounded-lg">
<p class="text-sm text-red-700 font-medium" x-text="testEmailError"></p>
</div>
</template>
<template x-if="testEmailSuccess">
<div class="mt-3 p-3 bg-green-100 border-2 border-green-500 rounded-lg">
<p class="text-sm text-green-700 font-medium" x-text="testEmailSuccess"></p>
</div>
</template>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Send a test email to verify the platform email configuration is working.
</p>
</div>
</div>
</div>
<!-- Shipping Settings Tab -->
<div x-show="activeTab === 'shipping'" x-transition>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-4">
Shipping & Carrier Configuration
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
Configure shipping carrier label URL prefixes. These are used to generate shipping label download links.
</p>
<!-- Carrier Label URL Settings -->
<div class="space-y-6">
<!-- Greco (Letzshop default) -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<span class="flex items-center gap-2">
<span class="px-2 py-0.5 text-xs font-medium bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200 rounded">Greco</span>
Label URL Prefix
</span>
</label>
<input
type="url"
x-model="shippingSettings.carrier_greco_label_url"
placeholder="https://dispatchweb.fr/Tracky/Home/"
class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
/>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
The shipment number will be appended to this URL. Default for Letzshop: <code class="bg-gray-100 dark:bg-gray-700 px-1 rounded">https://dispatchweb.fr/Tracky/Home/</code>
</p>
</div>
<!-- Colissimo -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<span class="flex items-center gap-2">
<span class="px-2 py-0.5 text-xs font-medium bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 rounded">Colissimo</span>
Label URL Prefix
</span>
</label>
<input
type="url"
x-model="shippingSettings.carrier_colissimo_label_url"
placeholder="https://www.laposte.fr/outils/suivre-vos-envois?code="
class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
/>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Enter the tracking URL prefix for Colissimo shipments.
</p>
</div>
<!-- XpressLogistics -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<span class="flex items-center gap-2">
<span class="px-2 py-0.5 text-xs font-medium bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded">XpressLogistics</span>
Label URL Prefix
</span>
</label>
<input
type="url"
x-model="shippingSettings.carrier_xpresslogistics_label_url"
placeholder="https://tracking.xpresslogistics.com/"
class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
/>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Enter the tracking URL prefix for XpressLogistics shipments.
</p>
</div>
</div>
<!-- Info Box -->
<div class="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<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 flex-shrink-0')"></span>
<div class="text-sm text-blue-800 dark:text-blue-200">
<p class="font-medium mb-1">How label URLs work</p>
<p>When viewing an order, the system will combine the URL prefix with the shipment number to create a downloadable label link.</p>
<p class="mt-1">Example: <code class="bg-blue-100 dark:bg-blue-800 px-1 rounded">https://dispatchweb.fr/Tracky/Home/</code> + <code class="bg-blue-100 dark:bg-blue-800 px-1 rounded">H74683403433</code></p>
</div>
</div>
</div>
<!-- Save Button -->
<div class="flex items-center justify-end pt-6 mt-6 border-t border-gray-200 dark:border-gray-700">
<button
@click="saveShippingSettings()"
:disabled="saving"
class="px-6 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="!saving">Save Shipping Settings</span>
<span x-show="saving">Saving...</span>
</button>
</div>
</div>
</div>
<!-- System Settings Tab -->
<div x-show="activeTab === 'system'" x-transition>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-4">
System Configuration
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
General system settings and configuration options.
</p>
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
<span x-html="$icon('cog', 'inline w-12 h-12 mb-4')"></span>
<p>System settings coming soon...</p>
</div>
</div>
</div>
<!-- Security Settings Tab -->
<div x-show="activeTab === 'security'" x-transition>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-4">
Security Configuration
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
Security and authentication settings.
</p>
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
<span x-html="$icon('shield-check', 'inline w-12 h-12 mb-4')"></span>
<p>Security settings coming soon...</p>
</div>
</div>
</div>
<!-- Notifications Settings Tab -->
<div x-show="activeTab === 'notifications'" x-transition>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-4">
Notification Settings
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
Configure platform notification preferences and delivery channels.
</p>
<!-- Quick Link to Full Notifications Page -->
<div class="mb-6 p-4 bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg">
<div class="flex items-start">
<span x-html="$icon('information-circle', 'w-5 h-5 text-purple-600 dark:text-purple-400 mt-0.5 mr-3 flex-shrink-0')"></span>
<div>
<p class="text-sm text-purple-800 dark:text-purple-200">
For detailed notification management including templates and delivery logs, visit the full
<a href="/admin/notifications-settings" class="font-medium underline hover:text-purple-600">Notifications page</a>
in Platform Monitoring.
</p>
</div>
</div>
</div>
<!-- Email Notifications Toggle -->
<div class="space-y-4 mb-6">
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div>
<p class="font-medium text-gray-700 dark:text-gray-200">Email Notifications</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Send notification emails for important events</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" x-model="notificationSettings.email_enabled" class="sr-only peer">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-300 dark:peer-focus:ring-purple-800 rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-purple-600"></div>
</label>
</div>
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div>
<p class="font-medium text-gray-700 dark:text-gray-200">In-App Notifications</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Show notifications in the admin interface</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" x-model="notificationSettings.in_app_enabled" class="sr-only peer">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-300 dark:peer-focus:ring-purple-800 rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-purple-600"></div>
</label>
</div>
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div>
<p class="font-medium text-gray-700 dark:text-gray-200">Critical Alerts Only</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Only receive notifications for critical system events</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" x-model="notificationSettings.critical_only" class="sr-only peer">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-300 dark:peer-focus:ring-purple-800 rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-purple-600"></div>
</label>
</div>
</div>
<!-- Save Button -->
<div class="flex items-center justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
<button
@click="saveNotificationSettings()"
:disabled="saving"
class="px-6 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="!saving">Save Notification Settings</span>
<span x-show="saving">Saving...</span>
</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('core_static', path='admin/js/settings.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,181 @@
{# app/templates/vendor/dashboard.html #}
{% extends "vendor/base.html" %}
{% from "shared/macros/feature_gate.html" import limit_warning, usage_bar, upgrade_card, tier_badge %}
{% from "shared/macros/alerts.html" import loading_state, error_state %}
{% from "shared/macros/tables.html" import table_wrapper, table_header %}
{% block title %}Dashboard{% endblock %}
{% block alpine_data %}vendorDashboard(){% endblock %}
{% from "shared/macros/feature_gate.html" import email_settings_warning %}
{% block content %}
<!-- Email Settings Warning -->
{{ email_settings_warning() }}
<!-- Limit Warnings -->
{{ limit_warning("orders") }}
{{ limit_warning("products") }}
<!-- Page Header with Refresh Button -->
{# noqa: FE-007 - Custom header with tier_badge alongside title #}
<div class="flex items-center justify-between my-6">
<div class="flex items-center gap-3">
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
Dashboard
</h2>
{{ tier_badge() }}
</div>
<button
@click="refresh()"
:disabled="loading"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="!loading" x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
<span x-show="loading" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="loading ? 'Loading...' : 'Refresh'"></span>
</button>
</div>
<!-- Loading State -->
{{ loading_state('Loading dashboard...') }}
<!-- Error State -->
{{ error_state('Error loading dashboard') }}
<!-- Vendor Info Card -->
{% include 'vendor/partials/vendor_info.html' %}
<!-- Upgrade Recommendation Card (shows when approaching/at limits) -->
{{ upgrade_card(class='mb-6') }}
<!-- Stats Cards -->
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<!-- Card: Total Products -->
<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('shopping-bag', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Total Products
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.products_count">
0
</p>
</div>
</div>
<!-- Card: Total Orders -->
<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('shopping-cart', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Total Orders
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.orders_count">
0
</p>
</div>
</div>
<!-- Card: Total Customers -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
<span x-html="$icon('users', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Total Customers
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.customers_count">
0
</p>
</div>
</div>
<!-- Card: Revenue -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-teal-500 bg-teal-100 rounded-full dark:text-teal-100 dark:bg-teal-500">
<span x-html="$icon('currency-euro', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Total Revenue
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatCurrency(stats.revenue)">
€0
</p>
</div>
</div>
</div>
<!-- Usage Overview -->
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-3">
<div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
{{ usage_bar("orders", "Monthly Orders") }}
</div>
<div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
{{ usage_bar("products", "Products") }}
</div>
<div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
{{ usage_bar("team_members", "Team Members") }}
</div>
</div>
<!-- Recent Orders Table -->
<div x-show="!loading && recentOrders.length > 0" class="mb-8">
{% call table_wrapper() %}
{{ table_header(['Order ID', 'Customer', 'Amount', 'Status', 'Date']) }}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="order in recentOrders" :key="order.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<td class="px-4 py-3">
<span class="text-xs" x-text="'#' + order.id"></span>
</td>
<td class="px-4 py-3 text-sm" x-text="order.customer_name">
</td>
<td class="px-4 py-3 text-sm font-semibold" x-text="formatCurrency(order.total_amount)">
</td>
<td class="px-4 py-3 text-xs">
<span class="px-2 py-1 font-semibold leading-tight rounded-full"
:class="{
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': order.status === 'completed',
'text-orange-700 bg-orange-100 dark:text-white dark:bg-orange-600': order.status === 'pending',
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': order.status === 'processing'
}"
x-text="order.status"></span>
</td>
<td class="px-4 py-3 text-sm" x-text="formatDate(order.created_at)">
</td>
</tr>
</template>
</tbody>
{% endcall %}
</div>
<!-- Getting Started Section -->
<div x-show="!loading && recentOrders.length === 0" class="w-full mb-8 overflow-hidden rounded-lg shadow-xs">
<div class="w-full p-8 bg-white dark:bg-gray-800 text-center">
<div class="text-6xl mb-4">🚀</div>
<h3 class="text-xl font-semibold text-gray-700 dark:text-gray-200 mb-2">
Welcome to Your Vendor Dashboard!
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-6">
Start by importing products from the marketplace to build your catalog.
</p>
<a href="/vendor/{{ vendor_code }}/marketplace"
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
<span x-html="$icon('download', 'w-4 h-4 mr-2')"></span>
Go to Marketplace Import
</a>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('core_static', path='vendor/js/dashboard.js') }}"></script>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
# app/modules/core/utils/__init__.py
"""Core module utilities."""
from .page_context import (
get_admin_context,
get_vendor_context,
get_storefront_context,
get_public_context,
)
__all__ = [
"get_admin_context",
"get_vendor_context",
"get_storefront_context",
"get_public_context",
]

View File

@@ -0,0 +1,328 @@
# app/modules/core/utils/page_context.py
"""
Shared page context helpers for HTML page routes.
These functions build template contexts that include common variables
needed across different frontends (admin, vendor, storefront, public).
"""
import logging
from fastapi import Request
from sqlalchemy.orm import Session
from app.core.config import settings
from app.modules.core.services.platform_settings_service import platform_settings_service
from app.utils.i18n import get_jinja2_globals
from models.database.user import User
from models.database.vendor import Vendor
logger = logging.getLogger(__name__)
def get_admin_context(
request: Request,
current_user: User,
db: Session | None = None,
**extra_context,
) -> dict:
"""
Build template context for admin dashboard pages.
Args:
request: FastAPI request object
current_user: Authenticated admin user
db: Optional database session
**extra_context: Additional variables for template
Returns:
Dictionary with request, user, and extra context
"""
context = {
"request": request,
"user": current_user,
}
if extra_context:
context.update(extra_context)
return context
def get_vendor_context(
request: Request,
db: Session,
current_user: User,
vendor_code: str,
**extra_context,
) -> dict:
"""
Build template context for vendor dashboard pages.
Resolves locale/currency using the platform settings service with
vendor override support:
1. Vendor's storefront_locale (if set)
2. Platform's default from PlatformSettingsService
3. Environment variable
4. Hardcoded fallback
Args:
request: FastAPI request object
db: Database session
current_user: Authenticated vendor user
vendor_code: Vendor subdomain/code
**extra_context: Additional variables for template
Returns:
Dictionary with request, user, vendor, resolved locale/currency, and extra context
"""
# Load vendor from database
vendor = db.query(Vendor).filter(Vendor.subdomain == vendor_code).first()
# Get platform defaults
platform_config = platform_settings_service.get_storefront_config(db)
# Resolve with vendor override
storefront_locale = platform_config["locale"]
storefront_currency = platform_config["currency"]
if vendor and vendor.storefront_locale:
storefront_locale = vendor.storefront_locale
context = {
"request": request,
"user": current_user,
"vendor": vendor,
"vendor_code": vendor_code,
"storefront_locale": storefront_locale,
"storefront_currency": storefront_currency,
"dashboard_language": vendor.dashboard_language if vendor else "en",
}
# Add any extra context
if extra_context:
context.update(extra_context)
logger.debug(
"[VENDOR_CONTEXT] Context built",
extra={
"vendor_id": vendor.id if vendor else None,
"vendor_code": vendor_code,
"storefront_locale": storefront_locale,
"storefront_currency": storefront_currency,
"extra_keys": list(extra_context.keys()) if extra_context else [],
},
)
return context
def get_storefront_context(
request: Request,
db: Session | None = None,
**extra_context,
) -> dict:
"""
Build template context for storefront (customer shop) pages.
Automatically includes vendor and theme from middleware request.state.
Additional context can be passed as keyword arguments.
Args:
request: FastAPI request object with vendor/theme in state
db: Optional database session for loading navigation pages
**extra_context: Additional variables for template (user, product_id, etc.)
Returns:
Dictionary with request, vendor, theme, navigation pages, and extra context
"""
# Import here to avoid circular imports
from app.modules.cms.services import content_page_service
# Extract from middleware state
vendor = getattr(request.state, "vendor", None)
platform = getattr(request.state, "platform", None)
theme = getattr(request.state, "theme", None)
clean_path = getattr(request.state, "clean_path", request.url.path)
vendor_context = getattr(request.state, "vendor_context", None)
# Get platform_id (default to 1 for OMS if not set)
platform_id = platform.id if platform else 1
# Get detection method from vendor_context
access_method = (
vendor_context.get("detection_method", "unknown")
if vendor_context
else "unknown"
)
if vendor is None:
logger.warning(
"[STOREFRONT_CONTEXT] Vendor not found in request.state",
extra={
"path": request.url.path,
"host": request.headers.get("host", ""),
"has_vendor": False,
},
)
# Calculate base URL for links
# - Domain/subdomain access: base_url = "/"
# - Path-based access: base_url = "/vendor/{vendor_code}/" or "/vendors/{vendor_code}/"
base_url = "/"
if access_method == "path" and vendor:
# Use the full_prefix from vendor_context to determine which pattern was used
full_prefix = (
vendor_context.get("full_prefix", "/vendor/")
if vendor_context
else "/vendor/"
)
base_url = f"{full_prefix}{vendor.subdomain}/"
# Load footer and header navigation pages from CMS if db session provided
footer_pages = []
header_pages = []
if db and vendor:
try:
vendor_id = vendor.id
# Get pages configured to show in footer
footer_pages = content_page_service.list_pages_for_vendor(
db,
platform_id=platform_id,
vendor_id=vendor_id,
footer_only=True,
include_unpublished=False,
)
# Get pages configured to show in header
header_pages = content_page_service.list_pages_for_vendor(
db,
platform_id=platform_id,
vendor_id=vendor_id,
header_only=True,
include_unpublished=False,
)
except Exception as e:
logger.error(
"[STOREFRONT_CONTEXT] Failed to load navigation pages",
extra={"error": str(e), "vendor_id": vendor.id if vendor else None},
)
# Resolve storefront locale and currency
storefront_config = {"locale": "fr-LU", "currency": "EUR"} # defaults
if db and vendor:
platform_config = platform_settings_service.get_storefront_config(db)
storefront_config["locale"] = platform_config["locale"]
storefront_config["currency"] = platform_config["currency"]
if vendor.storefront_locale:
storefront_config["locale"] = vendor.storefront_locale
context = {
"request": request,
"vendor": vendor,
"theme": theme,
"clean_path": clean_path,
"access_method": access_method,
"base_url": base_url,
"footer_pages": footer_pages,
"header_pages": header_pages,
"storefront_locale": storefront_config["locale"],
"storefront_currency": storefront_config["currency"],
}
# Add any extra context (user, product_id, category_slug, etc.)
if extra_context:
context.update(extra_context)
logger.debug(
"[STOREFRONT_CONTEXT] Context built",
extra={
"vendor_id": vendor.id if vendor else None,
"vendor_name": vendor.name if vendor else None,
"vendor_subdomain": vendor.subdomain if vendor else None,
"has_theme": theme is not None,
"access_method": access_method,
"base_url": base_url,
"storefront_locale": storefront_config["locale"],
"storefront_currency": storefront_config["currency"],
"footer_pages_count": len(footer_pages),
"header_pages_count": len(header_pages),
"extra_keys": list(extra_context.keys()) if extra_context else [],
},
)
return context
def get_public_context(
request: Request,
db: Session,
**extra_context,
) -> dict:
"""
Build context for public/marketing pages.
Includes platform info, i18n globals, and CMS navigation pages.
Args:
request: FastAPI request object
db: Database session
**extra_context: Additional variables for template
Returns:
Dictionary with request, platform info, i18n globals, and extra context
"""
# Import here to avoid circular imports
from app.modules.cms.services import content_page_service
# Get language from request state (set by middleware)
language = getattr(request.state, "language", "fr")
# Get platform from middleware (default to OMS platform_id=1)
platform = getattr(request.state, "platform", None)
platform_id = platform.id if platform else 1
# Get translation function
i18n_globals = get_jinja2_globals(language)
context = {
"request": request,
"platform": platform,
"platform_name": "Wizamart",
"platform_domain": settings.platform_domain,
"stripe_publishable_key": settings.stripe_publishable_key,
"trial_days": settings.stripe_trial_days,
}
# Add i18n globals (_, t, current_language, SUPPORTED_LANGUAGES, etc.)
context.update(i18n_globals)
# Load CMS pages for header, footer, and legal navigation
header_pages = []
footer_pages = []
legal_pages = []
try:
# Platform marketing pages (is_platform_page=True)
header_pages = content_page_service.list_platform_pages(
db, platform_id=platform_id, header_only=True, include_unpublished=False
)
footer_pages = content_page_service.list_platform_pages(
db, platform_id=platform_id, footer_only=True, include_unpublished=False
)
# For legal pages, we need to add footer support or use a different approach
# For now, legal pages come from footer pages with show_in_legal flag
legal_pages = [] # Will be handled separately if needed
logger.debug(
f"Loaded CMS pages: {len(header_pages)} header, {len(footer_pages)} footer, {len(legal_pages)} legal"
)
except Exception as e:
logger.error(f"Failed to load CMS navigation pages: {e}")
context["header_pages"] = header_pages
context["footer_pages"] = footer_pages
context["legal_pages"] = legal_pages
# Add any extra context
if extra_context:
context.update(extra_context)
return context