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:
@@ -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"]
|
||||
|
||||
19
app/modules/core/routes/api/admin.py
Normal file
19
app/modules/core/routes/api/admin.py
Normal 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"])
|
||||
127
app/modules/core/routes/api/admin_dashboard.py
Normal file
127
app/modules/core/routes/api/admin_dashboard.py
Normal 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),
|
||||
)
|
||||
716
app/modules/core/routes/api/admin_settings.py
Normal file
716
app/modules/core/routes/api/admin_settings.py
Normal 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)}",
|
||||
)
|
||||
190
app/modules/core/routes/api/public.py
Normal file
190
app/modules/core/routes/api/public.py
Normal 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.",
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
2
app/modules/core/routes/pages/__init__.py
Normal file
2
app/modules/core/routes/pages/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# app/modules/core/routes/pages/__init__.py
|
||||
"""Core module page routes."""
|
||||
159
app/modules/core/routes/pages/admin.py
Normal file
159
app/modules/core/routes/pages/admin.py
Normal 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),
|
||||
)
|
||||
69
app/modules/core/routes/pages/vendor.py
Normal file
69
app/modules/core/routes/pages/vendor.py
Normal 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),
|
||||
)
|
||||
56
app/modules/core/services/__init__.py
Normal file
56
app/modules/core/services/__init__.py
Normal 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",
|
||||
]
|
||||
289
app/modules/core/services/admin_settings_service.py
Normal file
289
app/modules/core/services/admin_settings_service.py
Normal 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()
|
||||
161
app/modules/core/services/auth_service.py
Normal file
161
app/modules/core/services/auth_service.py
Normal 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()
|
||||
307
app/modules/core/services/image_service.py
Normal file
307
app/modules/core/services/image_service.py
Normal 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()
|
||||
811
app/modules/core/services/menu_service.py
Normal file
811
app/modules/core/services/menu_service.py
Normal 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",
|
||||
]
|
||||
176
app/modules/core/services/platform_settings_service.py
Normal file
176
app/modules/core/services/platform_settings_service.py
Normal 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()
|
||||
295
app/modules/core/services/storage_service.py
Normal file
295
app/modules/core/services/storage_service.py
Normal 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",
|
||||
]
|
||||
191
app/modules/core/static/admin/js/dashboard.js
Normal file
191
app/modules/core/static/admin/js/dashboard.js
Normal 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');
|
||||
428
app/modules/core/static/admin/js/init-alpine.js
Normal file
428
app/modules/core/static/admin/js/init-alpine.js
Normal 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;
|
||||
246
app/modules/core/static/admin/js/login.js
Normal file
246
app/modules/core/static/admin/js/login.js
Normal 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');
|
||||
187
app/modules/core/static/admin/js/my-menu-config.js
Normal file
187
app/modules/core/static/admin/js/my-menu-config.js
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
509
app/modules/core/static/admin/js/settings.js
Normal file
509
app/modules/core/static/admin/js/settings.js
Normal 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');
|
||||
243
app/modules/core/static/shared/js/vendor-selector.js
Normal file
243
app/modules/core/static/shared/js/vendor-selector.js
Normal 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;
|
||||
295
app/modules/core/static/storefront/js/storefront-layout.js
Normal file
295
app/modules/core/static/storefront/js/storefront-layout.js
Normal 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');
|
||||
129
app/modules/core/static/vendor/js/dashboard.js
vendored
Normal file
129
app/modules/core/static/vendor/js/dashboard.js
vendored
Normal 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'
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
270
app/modules/core/static/vendor/js/init-alpine.js
vendored
Normal file
270
app/modules/core/static/vendor/js/init-alpine.js
vendored
Normal 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;
|
||||
141
app/modules/core/templates/core/admin/dashboard.html
Normal file
141
app/modules/core/templates/core/admin/dashboard.html
Normal 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 %}
|
||||
174
app/modules/core/templates/core/admin/my-menu-config.html
Normal file
174
app/modules/core/templates/core/admin/my-menu-config.html
Normal file
@@ -0,0 +1,174 @@
|
||||
{# app/templates/admin/my-menu-config.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/alerts.html' import alert_dynamic, error_state %}
|
||||
{% from 'shared/macros/headers.html' import page_header %}
|
||||
|
||||
{% block title %}My Menu{% endblock %}
|
||||
|
||||
{% block alpine_data %}adminMyMenuConfig(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ page_header('My Menu Configuration', subtitle='Customize your personal admin sidebar', back_url='/admin/settings') }}
|
||||
|
||||
{{ alert_dynamic(type='success', title='Success', message_var='successMessage', show_condition='successMessage') }}
|
||||
{{ error_state('Error', show_condition='error') }}
|
||||
|
||||
<!-- Info Box -->
|
||||
<div class="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<div class="flex items-start">
|
||||
<span x-html="$icon('information-circle', 'w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 mr-3')"></span>
|
||||
<div>
|
||||
<p class="text-sm text-blue-800 dark:text-blue-200">
|
||||
This configures <strong>your personal</strong> admin sidebar menu. These settings only affect your view.
|
||||
</p>
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300 mt-1">
|
||||
To configure menus for platform admins or vendors, go to <a href="/admin/platforms" class="underline hover:no-underline">Platforms</a> and select a platform's Menu Configuration.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid gap-4 mb-6 md:grid-cols-3">
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
|
||||
<span x-html="$icon('view-grid', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Total Items</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="menuConfig?.total_items || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
|
||||
<span x-html="$icon('eye', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Visible</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="menuConfig?.visible_items || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-gray-500 bg-gray-100 rounded-full dark:text-gray-100 dark:bg-gray-600">
|
||||
<span x-html="$icon('eye-off', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Hidden</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="menuConfig?.hidden_items || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Toggle visibility for menu items. Mandatory items cannot be hidden.
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="showAll()"
|
||||
:disabled="saving"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600"
|
||||
>
|
||||
<span x-html="$icon('eye', 'w-4 h-4 mr-2')"></span>
|
||||
Show All
|
||||
</button>
|
||||
<button
|
||||
@click="resetToDefaults()"
|
||||
:disabled="saving"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600"
|
||||
>
|
||||
<span x-html="$icon('eye-off', 'w-4 h-4 mr-2')"></span>
|
||||
Hide All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading" class="flex items-center justify-center py-12 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<span x-html="$icon('refresh', 'w-8 h-8 animate-spin text-purple-600')"></span>
|
||||
<span class="ml-3 text-gray-500 dark:text-gray-400">Loading menu configuration...</span>
|
||||
</div>
|
||||
|
||||
<!-- Menu Items by Section -->
|
||||
<div x-show="!loading" class="space-y-6">
|
||||
<template x-for="section in groupedItems" :key="section.id">
|
||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 overflow-hidden">
|
||||
<!-- Section Header -->
|
||||
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="section.label || 'Main'"></h3>
|
||||
<span
|
||||
x-show="section.isSuperAdminOnly"
|
||||
class="ml-2 px-2 py-0.5 text-xs font-medium rounded bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200"
|
||||
>
|
||||
Super Admin Only
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400" x-text="`${section.visibleCount}/${section.items.length} visible`"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section Items -->
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
<template x-for="item in section.items" :key="item.id">
|
||||
<div class="flex items-center justify-between px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/30">
|
||||
<div class="flex items-center">
|
||||
<span x-html="$icon(item.icon, 'w-5 h-5 text-gray-400 mr-3')"></span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-200" x-text="item.label"></p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500" x-text="item.url"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Mandatory Badge -->
|
||||
<span
|
||||
x-show="item.is_mandatory"
|
||||
class="px-2 py-0.5 text-xs font-medium rounded bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
|
||||
>
|
||||
Mandatory
|
||||
</span>
|
||||
|
||||
<!-- Toggle Switch -->
|
||||
<button
|
||||
@click="toggleVisibility(item)"
|
||||
:disabled="item.is_mandatory || saving"
|
||||
:class="{
|
||||
'bg-purple-600': item.is_visible,
|
||||
'bg-gray-200 dark:bg-gray-600': !item.is_visible,
|
||||
'opacity-50 cursor-not-allowed': item.is_mandatory
|
||||
}"
|
||||
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2"
|
||||
role="switch"
|
||||
:aria-checked="item.is_visible"
|
||||
>
|
||||
<span
|
||||
:class="{
|
||||
'translate-x-5': item.is_visible,
|
||||
'translate-x-0': !item.is_visible
|
||||
}"
|
||||
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div x-show="groupedItems.length === 0" class="bg-white rounded-lg shadow-xs dark:bg-gray-800 p-8 text-center">
|
||||
<span x-html="$icon('view-grid', 'w-12 h-12 mx-auto text-gray-400')"></span>
|
||||
<p class="mt-4 text-gray-500 dark:text-gray-400">No menu items available.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="{{ url_for('core_static', path='admin/js/my-menu-config.js') }}"></script>
|
||||
{% endblock %}
|
||||
782
app/modules/core/templates/core/admin/settings.html
Normal file
782
app/modules/core/templates/core/admin/settings.html
Normal 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 %}
|
||||
181
app/modules/core/templates/core/vendor/dashboard.html
vendored
Normal file
181
app/modules/core/templates/core/vendor/dashboard.html
vendored
Normal 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 %}
|
||||
1406
app/modules/core/templates/core/vendor/settings.html
vendored
Normal file
1406
app/modules/core/templates/core/vendor/settings.html
vendored
Normal file
File diff suppressed because it is too large
Load Diff
16
app/modules/core/utils/__init__.py
Normal file
16
app/modules/core/utils/__init__.py
Normal 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",
|
||||
]
|
||||
328
app/modules/core/utils/page_context.py
Normal file
328
app/modules/core/utils/page_context.py
Normal 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
|
||||
Reference in New Issue
Block a user