feat: consolidate media service, add merchant users page, fix metrics overlap
- Merge ImageService into MediaService with WebP variant generation, DB-backed storage stats, and module-driven media usage discovery via new MediaUsageProviderProtocol - Add merchant users admin page with scoped user listing, stats endpoint, template, JS, and i18n strings (de/en/fr/lb) - Fix merchant user metrics so Owners and Team Members are mutually exclusive (filter team_members on user_type="member" and exclude owner IDs) ensuring stat cards add up correctly - Update billing and monitoring services to use media_service - Update subscription-billing and feature-gating docs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,6 @@ 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
|
||||
@@ -16,7 +15,6 @@ from app.modules.core.services.admin_settings_service import (
|
||||
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.menu_discovery_service import (
|
||||
DiscoveredMenuItem,
|
||||
@@ -57,9 +55,6 @@ __all__ = [
|
||||
"DiscoveredMenuItem",
|
||||
"DiscoveredMenuSection",
|
||||
"menu_discovery_service",
|
||||
# Image
|
||||
"ImageService",
|
||||
"image_service",
|
||||
# Storage
|
||||
"StorageBackend",
|
||||
"LocalStorageBackend",
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
# 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,
|
||||
store_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
|
||||
store_id: Store 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(store_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 store {store_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, store_id: int, product_id: int | None, filename: str
|
||||
) -> str:
|
||||
"""Generate unique hash for image.
|
||||
|
||||
Args:
|
||||
store_id: Store ID
|
||||
product_id: Product ID (optional)
|
||||
filename: Original filename
|
||||
|
||||
Returns:
|
||||
8-character hex hash
|
||||
"""
|
||||
timestamp = datetime.utcnow().isoformat()
|
||||
content = f"{store_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()
|
||||
Reference in New Issue
Block a user