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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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