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:
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",
|
||||
]
|
||||
Reference in New Issue
Block a user