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:
2026-02-07 21:17:11 +01:00
parent 4cb2bda575
commit 2250054ba2
30 changed files with 1220 additions and 805 deletions

View File

@@ -3,97 +3,33 @@
Admin image management endpoints.
Provides:
- Image upload with automatic processing
- Image deletion
- Storage statistics
- Storage statistics (delegates to MediaService)
"""
import logging
from fastapi import APIRouter, Depends, File, Form, UploadFile
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.modules.core.services.image_service import image_service
from app.core.database import get_db
from app.modules.cms.services.media_service import media_service
from models.schema.auth import UserContext
from app.modules.cms.schemas.image import (
ImageDeleteResponse,
ImageStorageStats,
ImageUploadResponse,
)
from app.modules.cms.schemas.image import ImageStorageStats
admin_images_router = APIRouter(prefix="/images")
logger = logging.getLogger(__name__)
@admin_images_router.post("/upload", response_model=ImageUploadResponse)
async def upload_image(
file: UploadFile = File(...),
store_id: int = Form(...),
product_id: int | None = Form(None),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Upload and process an image.
The image will be:
- Converted to WebP format
- Resized to multiple variants (original, 800px, 200px)
- Stored in a sharded directory structure
Args:
file: Image file to upload
store_id: Store ID for the image
product_id: Optional product ID
Returns:
Image URLs and metadata
"""
# Read file content
content = await file.read()
# Delegate all validation and processing to service
result = image_service.upload_product_image(
file_content=content,
filename=file.filename or "image.jpg",
content_type=file.content_type,
store_id=store_id,
product_id=product_id,
)
logger.info(f"Image uploaded: {result['id']} for store {store_id}")
return ImageUploadResponse(success=True, image=result)
@admin_images_router.delete("/{image_hash}", response_model=ImageDeleteResponse)
async def delete_image(
image_hash: str,
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Delete an image and all its variants.
Args:
image_hash: The image ID/hash
Returns:
Deletion status
"""
deleted = image_service.delete_product_image(image_hash)
if deleted:
logger.info(f"Image deleted: {image_hash}")
return ImageDeleteResponse(success=True, message="Image deleted successfully")
else:
return ImageDeleteResponse(success=False, message="Image not found")
@admin_images_router.get("/stats", response_model=ImageStorageStats)
async def get_storage_stats(
current_admin: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Get image storage statistics.
Returns:
Storage metrics including file counts, sizes, and directory info
"""
stats = image_service.get_storage_stats()
stats = media_service.get_storage_stats(db)
return ImageStorageStats(**stats)

View File

@@ -257,17 +257,29 @@ def get_media_usage(
"""
Get where this media file is being used.
- Check products using this media
- Return list of usage
Discovers usage from all registered module providers.
"""
# Service will raise MediaNotFoundException if not found
usage = media_service.get_media_usage(
# Verify media belongs to store (raises MediaNotFoundException if not found)
media_service.get_media(
db=db,
store_id=current_user.token_store_id,
media_id=media_id,
)
return MediaUsageResponse(**usage)
# Discover usage from registered providers
from app.modules.registry import MODULES
usage = []
for module in MODULES.values():
provider = module.get_media_usage_provider_instance()
if provider:
usage.extend(provider.get_media_usage(db, media_id))
return MediaUsageResponse(
media_id=media_id,
usage=usage,
total_usage_count=len(usage),
)
@store_media_router.post("/optimize/{media_id}", response_model=OptimizationResultResponse)

View File

@@ -43,20 +43,17 @@ from app.modules.cms.schemas.media import (
MediaListResponse,
MediaMetadataUpdate,
MediaUploadResponse,
MediaUsageItem,
MediaUsageResponse,
MessageResponse,
MultipleUploadResponse,
OptimizationResultResponse,
ProductUsageInfo,
UploadedFileInfo,
)
# Image schemas
from app.modules.cms.schemas.image import (
ImageDeleteResponse,
ImageStorageStats,
ImageUploadResponse,
ImageUrls,
)
# Theme schemas
@@ -109,13 +106,10 @@ __all__ = [
"MessageResponse",
"MultipleUploadResponse",
"OptimizationResultResponse",
"ProductUsageInfo",
"MediaUsageItem",
"UploadedFileInfo",
# Image
"ImageDeleteResponse",
"ImageStorageStats",
"ImageUploadResponse",
"ImageUrls",
# Theme
"ThemeDeleteResponse",
"ThemePresetListResponse",

View File

@@ -6,33 +6,6 @@ Pydantic schemas for image operations.
from pydantic import BaseModel
class ImageUrls(BaseModel):
"""URLs for image variants."""
original: str
medium: str | None = None # 800px variant
thumb: str | None = None # 200px variant
# Allow arbitrary keys for flexibility
class Config:
extra = "allow"
class ImageUploadResponse(BaseModel):
"""Response from image upload."""
success: bool
image: dict | None = None
error: str | None = None
class ImageDeleteResponse(BaseModel):
"""Response from image deletion."""
success: bool
message: str
class ImageStorageStats(BaseModel):
"""Image storage statistics."""

View File

@@ -164,22 +164,21 @@ class MediaMetadataUpdate(BaseModel):
# ============================================================================
class ProductUsageInfo(BaseModel):
"""Information about product using this media."""
class MediaUsageItem(BaseModel):
"""Information about an entity using this media."""
product_id: int
product_name: str
usage_type: str # main_image, gallery, variant, etc.
entity_type: str # Defined by provider (e.g. "product")
entity_id: int
entity_name: str
usage_type: str # Defined by provider (e.g. "main_image", "gallery")
class MediaUsageResponse(BaseModel):
"""Response showing where media is being used."""
media_id: int | None = None
products: list[ProductUsageInfo] = []
other_usage: list[dict[str, Any]] = []
usage: list[MediaUsageItem] = []
total_usage_count: int = 0
message: str | None = None
# ============================================================================

View File

@@ -18,7 +18,7 @@ from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from sqlalchemy import or_
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from app.modules.cms.exceptions import (
@@ -69,6 +69,14 @@ MAX_FILE_SIZES = {
# Thumbnail settings
THUMBNAIL_SIZE = (200, 200)
# Image variant settings (ported from ImageService)
IMAGE_VARIANT_QUALITY = 85
IMAGE_MAX_DIMENSION = 2000
IMAGE_VARIANT_SIZES = {
"800": 800,
"200": 200,
}
class MediaService:
"""Service for store media library operations."""
@@ -139,38 +147,80 @@ class MediaService:
logger.warning(f"Could not get image dimensions: {e}")
return None
def _generate_thumbnail(
self, source_path: Path, store_id: int
) -> str | None:
"""Generate thumbnail for image file."""
def _resize_image(self, img: "Image.Image", max_dimension: int) -> "Image.Image":
"""Resize image while maintaining aspect ratio.
Args:
img: PIL Image
max_dimension: Maximum width or height
Returns:
Resized PIL Image (or original if already smaller)
"""
width, height = img.size
if width <= max_dimension and height <= max_dimension:
return img
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))
from PIL import Image
return img.resize((new_width, new_height), Image.Resampling.LANCZOS)
def _generate_image_variants(
self, source_path: Path, filename_stem: str
) -> dict[str, str]:
"""Generate WebP image variants at multiple sizes.
Args:
source_path: Path to the original image file
filename_stem: UUID stem for naming variants
Returns:
Dict of size_name -> relative path (from UPLOAD_DIR)
"""
try:
from PIL import Image
# Create thumbnails directory
thumb_dir = self._get_store_upload_path(store_id, "thumbnails")
self._ensure_upload_dir(thumb_dir)
variants: dict[str, str] = {}
parent_dir = source_path.parent
# Generate thumbnail filename
thumb_filename = f"thumb_{source_path.name}"
thumb_path = thumb_dir / thumb_filename
# Create thumbnail
with Image.open(source_path) as img:
img.thumbnail(THUMBNAIL_SIZE)
# Convert to RGB if needed (for PNG with transparency)
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
img.save(thumb_path, "JPEG", quality=85)
# Return relative path
return str(thumb_path.relative_to(UPLOAD_DIR))
# Resize original if larger than max dimension
img = self._resize_image(img, IMAGE_MAX_DIMENSION)
for size_name, max_dim in IMAGE_VARIANT_SIZES.items():
variant_img = self._resize_image(img.copy(), max_dim)
variant_filename = f"{filename_stem}_{size_name}.webp"
variant_path = parent_dir / variant_filename
variant_img.save(
variant_path, "WEBP", quality=IMAGE_VARIANT_QUALITY
)
variants[size_name] = str(
variant_path.relative_to(UPLOAD_DIR)
)
logger.debug(
f"Generated {size_name}px variant: {variant_path}"
)
return variants
except ImportError:
logger.debug("PIL not available, skipping thumbnail generation")
return None
logger.debug("PIL not available, skipping variant generation")
return {}
except Exception as e:
logger.warning(f"Could not generate thumbnail: {e}")
return None
logger.warning(f"Could not generate image variants: {e}")
return {}
async def upload_file(
self,
@@ -214,15 +264,24 @@ class MediaService:
# Get MIME type
mime_type, _ = mimetypes.guess_type(filename)
# Get image dimensions and generate thumbnail
# Get image dimensions and generate variants
width, height = None, None
thumbnail_path = None
extra_metadata: dict[str, Any] = {}
if media_type == "image":
dimensions = self._get_image_dimensions(file_path)
if dimensions:
width, height = dimensions
thumbnail_path = self._generate_thumbnail(file_path, store_id)
# Generate WebP variants (800px, 200px)
stem = unique_filename.rsplit(".", 1)[0] if "." in unique_filename else unique_filename
variants = self._generate_image_variants(file_path, stem)
if variants:
extra_metadata["variants"] = variants
# Use the 200px variant as thumbnail
if "200" in variants:
thumbnail_path = variants["200"]
# Create database record
media_file = MediaFile(
@@ -237,6 +296,7 @@ class MediaService:
height=height,
thumbnail_path=thumbnail_path,
folder=folder,
extra_metadata=extra_metadata if extra_metadata else None,
)
db.add(media_file)
@@ -410,6 +470,13 @@ class MediaService:
if thumb_path.exists():
thumb_path.unlink()
# Delete variant files
if media.extra_metadata and "variants" in media.extra_metadata:
for variant_path in media.extra_metadata["variants"].values():
vpath = UPLOAD_DIR / variant_path
if vpath.exists():
vpath.unlink()
# Delete database record
db.delete(media)
@@ -417,9 +484,38 @@ class MediaService:
return True
# Note: Product-specific methods (attach_to_product, detach_from_product,
# get_media_usage) have been moved to catalog.services.product_media_service
# CMS media_service is now consumer-agnostic.
def get_storage_stats(self, db: Session) -> dict:
"""Get storage statistics from MediaFile records.
Returns:
Dict with storage metrics for health monitoring.
"""
total_files = db.query(func.count(MediaFile.id)).scalar() or 0
total_size = db.query(func.sum(MediaFile.file_size)).scalar() or 0
# Count distinct folders as proxy for directory count
directory_count = (
db.query(func.count(func.distinct(MediaFile.folder))).scalar() or 0
)
# Image-specific stats
image_count = (
db.query(func.count(MediaFile.id))
.filter(MediaFile.media_type == "image")
.scalar()
or 0
)
return {
"total_files": total_files,
"total_size_bytes": total_size,
"total_size_mb": round(total_size / (1024 * 1024), 2) if total_size else 0,
"total_size_gb": round(total_size / (1024 * 1024 * 1024), 3) if total_size else 0,
"directory_count": directory_count,
"max_files_per_dir": 0, # Not applicable for DB-backed tracking
"avg_files_per_dir": round(total_files / directory_count, 1) if directory_count else 0,
"products_estimated": image_count,
}
# Create service instance