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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user