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)
|
||||
|
||||
Reference in New Issue
Block a user