diff --git a/app/modules/base.py b/app/modules/base.py index 0c45bbbc..d2645ed3 100644 --- a/app/modules/base.py +++ b/app/modules/base.py @@ -45,6 +45,7 @@ if TYPE_CHECKING: from pydantic import BaseModel from app.modules.contracts.audit import AuditProviderProtocol + from app.modules.contracts.cms import MediaUsageProviderProtocol from app.modules.contracts.features import FeatureProviderProtocol from app.modules.contracts.metrics import MetricsProviderProtocol from app.modules.contracts.widgets import DashboardWidgetProviderProtocol @@ -477,6 +478,14 @@ class ModuleDefinition: # The provider will be discovered by billing's FeatureAggregator service. feature_provider: "Callable[[], FeatureProviderProtocol] | None" = None + # ========================================================================= + # Media Usage Provider (Module-Driven Media Usage Tracking) + # ========================================================================= + # Callable that returns a MediaUsageProviderProtocol implementation. + # Modules that use media files (catalog, etc.) can register a provider + # to report where media is being used. + media_usage_provider: "Callable[[], MediaUsageProviderProtocol] | None" = None + # ========================================================================= # Menu Item Methods (Legacy - uses menu_items dict of IDs) # ========================================================================= @@ -929,6 +938,24 @@ class ModuleDefinition: return None return self.feature_provider() + # ========================================================================= + # Media Usage Provider Methods + # ========================================================================= + + def has_media_usage_provider(self) -> bool: + """Check if this module has a media usage provider.""" + return self.media_usage_provider is not None + + def get_media_usage_provider_instance(self) -> "MediaUsageProviderProtocol | None": + """Get the media usage provider instance for this module. + + Returns: + MediaUsageProviderProtocol instance, or None + """ + if self.media_usage_provider is None: + return None + return self.media_usage_provider() + # ========================================================================= # Magic Methods # ========================================================================= diff --git a/app/modules/billing/services/capacity_forecast_service.py b/app/modules/billing/services/capacity_forecast_service.py index d817ebd0..b5735107 100644 --- a/app/modules/billing/services/capacity_forecast_service.py +++ b/app/modules/billing/services/capacity_forecast_service.py @@ -47,7 +47,7 @@ class CapacityForecastService: Should be called by a daily background job. """ - from app.modules.core.services.image_service import image_service + from app.modules.cms.services.media_service import media_service from app.modules.monitoring.services.platform_health_service import platform_health_service now = datetime.now(UTC) @@ -108,7 +108,7 @@ class CapacityForecastService: # Storage metrics try: - image_stats = image_service.get_storage_stats() + image_stats = media_service.get_storage_stats(db) storage_gb = image_stats.get("total_size_gb", 0) except Exception: storage_gb = 0 diff --git a/app/modules/catalog/definition.py b/app/modules/catalog/definition.py index 4906b7b7..aa914b82 100644 --- a/app/modules/catalog/definition.py +++ b/app/modules/catalog/definition.py @@ -43,6 +43,13 @@ def _get_feature_provider(): return catalog_feature_provider +def _get_media_usage_provider(): + """Lazy import of media usage provider to avoid circular imports.""" + from app.modules.catalog.services.product_media_service import product_media_service + + return product_media_service + + # Catalog module definition catalog_module = ModuleDefinition( code="catalog", @@ -122,6 +129,7 @@ catalog_module = ModuleDefinition( # Metrics provider for dashboard statistics metrics_provider=_get_metrics_provider, feature_provider=_get_feature_provider, + media_usage_provider=_get_media_usage_provider, ) diff --git a/app/modules/catalog/services/product_media_service.py b/app/modules/catalog/services/product_media_service.py index 577760c3..45601ec8 100644 --- a/app/modules/catalog/services/product_media_service.py +++ b/app/modules/catalog/services/product_media_service.py @@ -231,6 +231,26 @@ class ProductMediaService: "total_count": len(associations), } + def get_media_usage(self, db: Session, media_id: int) -> list[dict]: + """Implement MediaUsageProviderProtocol. + + Returns list of usage records for a given media file across products. + """ + rows = ( + db.query(ProductMedia) + .filter(ProductMedia.media_id == media_id) + .all() + ) + return [ + { + "entity_type": "product", + "entity_id": r.product_id, + "entity_name": r.product.name if r.product else "Unknown", + "usage_type": r.usage_type, + } + for r in rows + ] + def set_main_image( self, db: Session, diff --git a/app/modules/cms/routes/api/admin_images.py b/app/modules/cms/routes/api/admin_images.py index e5f5d00e..379c3b62 100644 --- a/app/modules/cms/routes/api/admin_images.py +++ b/app/modules/cms/routes/api/admin_images.py @@ -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) diff --git a/app/modules/cms/routes/api/store_media.py b/app/modules/cms/routes/api/store_media.py index f59d72f2..6cecba75 100644 --- a/app/modules/cms/routes/api/store_media.py +++ b/app/modules/cms/routes/api/store_media.py @@ -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) diff --git a/app/modules/cms/schemas/__init__.py b/app/modules/cms/schemas/__init__.py index 21212a47..e192c164 100644 --- a/app/modules/cms/schemas/__init__.py +++ b/app/modules/cms/schemas/__init__.py @@ -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", diff --git a/app/modules/cms/schemas/image.py b/app/modules/cms/schemas/image.py index 0813b380..00967b89 100644 --- a/app/modules/cms/schemas/image.py +++ b/app/modules/cms/schemas/image.py @@ -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.""" diff --git a/app/modules/cms/schemas/media.py b/app/modules/cms/schemas/media.py index f18e5512..91dcd2bc 100644 --- a/app/modules/cms/schemas/media.py +++ b/app/modules/cms/schemas/media.py @@ -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 # ============================================================================ diff --git a/app/modules/cms/services/media_service.py b/app/modules/cms/services/media_service.py index 8936f7cb..d96c52f0 100644 --- a/app/modules/cms/services/media_service.py +++ b/app/modules/cms/services/media_service.py @@ -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 diff --git a/app/modules/contracts/__init__.py b/app/modules/contracts/__init__.py index 6415aa98..09c31dfe 100644 --- a/app/modules/contracts/__init__.py +++ b/app/modules/contracts/__init__.py @@ -50,7 +50,7 @@ from app.modules.contracts.audit import ( AuditProviderProtocol, ) from app.modules.contracts.base import ServiceProtocol -from app.modules.contracts.cms import ContentServiceProtocol +from app.modules.contracts.cms import ContentServiceProtocol, MediaUsageProviderProtocol from app.modules.contracts.features import ( FeatureDeclaration, FeatureProviderProtocol, @@ -78,6 +78,7 @@ __all__ = [ "ServiceProtocol", # CMS protocols "ContentServiceProtocol", + "MediaUsageProviderProtocol", # Audit protocols "AuditEvent", "AuditProviderProtocol", diff --git a/app/modules/contracts/cms.py b/app/modules/contracts/cms.py index e0e09c61..b99b5b70 100644 --- a/app/modules/contracts/cms.py +++ b/app/modules/contracts/cms.py @@ -122,6 +122,22 @@ class ContentServiceProtocol(Protocol): ... +@runtime_checkable +class MediaUsageProviderProtocol(Protocol): + """Protocol for modules that track media file usage.""" + + def get_media_usage(self, db: "Session", media_id: int) -> list[dict]: + """Return list of usage records. + + Each dict should contain: + entity_type: str (e.g. "product") + entity_id: int + entity_name: str + usage_type: str (e.g. "main_image", "gallery") + """ + ... + + @runtime_checkable class MediaServiceProtocol(Protocol): """ diff --git a/app/modules/core/services/__init__.py b/app/modules/core/services/__init__.py index 1a099207..6fe9e01d 100644 --- a/app/modules/core/services/__init__.py +++ b/app/modules/core/services/__init__.py @@ -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", diff --git a/app/modules/core/services/image_service.py b/app/modules/core/services/image_service.py deleted file mode 100644 index f95694a8..00000000 --- a/app/modules/core/services/image_service.py +++ /dev/null @@ -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() diff --git a/app/modules/messaging/services/message_attachment_service.py b/app/modules/messaging/services/message_attachment_service.py index fad02d8d..6c6269a6 100644 --- a/app/modules/messaging/services/message_attachment_service.py +++ b/app/modules/messaging/services/message_attachment_service.py @@ -206,8 +206,8 @@ class MessageAttachmentService: by the static file handler or a dedicated download endpoint. """ # Convert local path to URL path - # Assumes files are served from /static/uploads or similar - return f"/static/{file_path}" + # Files are served via the /uploads mount in main.py + return f"/{file_path}" def get_file_content(self, file_path: str) -> bytes | None: """Read file content from storage.""" diff --git a/app/modules/monitoring/services/platform_health_service.py b/app/modules/monitoring/services/platform_health_service.py index 57b57785..9ec2b76e 100644 --- a/app/modules/monitoring/services/platform_health_service.py +++ b/app/modules/monitoring/services/platform_health_service.py @@ -16,7 +16,7 @@ import psutil from sqlalchemy import func, text from sqlalchemy.orm import Session -from app.modules.core.services.image_service import image_service +from app.modules.cms.services.media_service import media_service from app.modules.inventory.models import Inventory from app.modules.orders.models import Order from app.modules.catalog.models import Product @@ -108,9 +108,9 @@ class PlatformHealthService: "inventory_count": inventory_count, } - def get_image_storage_metrics(self) -> dict: + def get_image_storage_metrics(self, db: Session) -> dict: """Get image storage statistics.""" - stats = image_service.get_storage_stats() + stats = media_service.get_storage_stats(db) return { "total_files": stats["total_files"], "total_size_mb": stats["total_size_mb"], @@ -134,7 +134,7 @@ class PlatformHealthService: products_by_store = {name or "Unknown": count for name, count in store_counts} # Image storage - image_stats = image_service.get_storage_stats() + image_stats = media_service.get_storage_stats(db) # Database size db_size = self._get_database_size(db) @@ -282,7 +282,7 @@ class PlatformHealthService: database = self.get_database_metrics(db) # Image storage metrics - image_storage = self.get_image_storage_metrics() + image_storage = self.get_image_storage_metrics(db) # Subscription capacity subscription_capacity = self.get_subscription_capacity(db) diff --git a/app/modules/tenancy/definition.py b/app/modules/tenancy/definition.py index 008a2a34..5c2c3316 100644 --- a/app/modules/tenancy/definition.py +++ b/app/modules/tenancy/definition.py @@ -86,6 +86,7 @@ tenancy_module = ModuleDefinition( "merchants", "stores", "admin-users", + "merchant-users", ], FrontendType.STORE: [ "team", @@ -95,11 +96,10 @@ tenancy_module = ModuleDefinition( menus={ FrontendType.ADMIN: [ MenuSectionDefinition( - id="superAdmin", - label_key="tenancy.menu.super_admin", - icon="shield", + id="userManagement", + label_key="tenancy.menu.user_management", + icon="users", order=10, - is_super_admin_only=True, items=[ MenuItemDefinition( id="admin-users", @@ -108,6 +108,15 @@ tenancy_module = ModuleDefinition( route="/admin/admin-users", order=10, is_mandatory=True, + is_super_admin_only=True, + ), + MenuItemDefinition( + id="merchant-users", + label_key="tenancy.menu.merchant_users", + icon="user-group", + route="/admin/merchant-users", + order=20, + is_mandatory=True, ), ], ), diff --git a/app/modules/tenancy/locales/de.json b/app/modules/tenancy/locales/de.json index 02ef750f..0c1c0792 100644 --- a/app/modules/tenancy/locales/de.json +++ b/app/modules/tenancy/locales/de.json @@ -1,4 +1,8 @@ { + "menu": { + "user_management": "Benutzerverwaltung", + "merchant_users": "Händler-Benutzer" + }, "team": { "title": "Team", "members": "Mitglieder", diff --git a/app/modules/tenancy/locales/en.json b/app/modules/tenancy/locales/en.json index c199fda2..0fce670f 100644 --- a/app/modules/tenancy/locales/en.json +++ b/app/modules/tenancy/locales/en.json @@ -1,4 +1,8 @@ { + "menu": { + "user_management": "User Management", + "merchant_users": "Merchant Users" + }, "team": { "title": "Team", "members": "Members", diff --git a/app/modules/tenancy/locales/fr.json b/app/modules/tenancy/locales/fr.json index 32c8aa65..75fe9229 100644 --- a/app/modules/tenancy/locales/fr.json +++ b/app/modules/tenancy/locales/fr.json @@ -1,4 +1,8 @@ { + "menu": { + "user_management": "Gestion des utilisateurs", + "merchant_users": "Utilisateurs marchands" + }, "team": { "title": "Équipe", "members": "Membres", diff --git a/app/modules/tenancy/locales/lb.json b/app/modules/tenancy/locales/lb.json index 694123a1..cc902b67 100644 --- a/app/modules/tenancy/locales/lb.json +++ b/app/modules/tenancy/locales/lb.json @@ -1,4 +1,8 @@ { + "menu": { + "user_management": "Benotzerverwaltung", + "merchant_users": "Händler-Benotzer" + }, "team": { "title": "Team", "members": "Memberen", diff --git a/app/modules/tenancy/routes/api/admin_platform_users.py b/app/modules/tenancy/routes/api/admin_platform_users.py index b14ccec3..f14651e3 100644 --- a/app/modules/tenancy/routes/api/admin_platform_users.py +++ b/app/modules/tenancy/routes/api/admin_platform_users.py @@ -38,6 +38,7 @@ def get_all_users( per_page: int = Query(10, ge=1, le=100), search: str = Query("", description="Search by username or email"), role: str = Query("", description="Filter by role"), + scope: str = Query("", description="Filter scope: 'merchant' for merchant owners and team members"), is_active: str = Query("", description="Filter by active status"), db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), @@ -54,11 +55,35 @@ def get_all_users( per_page=per_page, search=search if search else None, role=role if role else None, + scope=scope if scope else None, is_active=is_active_bool, ) + if scope == "merchant": + items = [ + UserDetailResponse( + id=user.id, + email=user.email, + username=user.username, + role=user.role, + is_active=user.is_active, + last_login=user.last_login, + created_at=user.created_at, + updated_at=user.updated_at, + first_name=user.first_name, + last_name=user.last_name, + full_name=user.full_name, + is_email_verified=user.is_email_verified, + owned_merchants_count=len(user.owned_merchants) if user.owned_merchants else 0, + store_memberships_count=len(user.store_memberships) if user.store_memberships else 0, + ) + for user in users + ] + else: + items = [UserResponse.model_validate(user) for user in users] + return UserListResponse( - items=[UserResponse.model_validate(user) for user in users], + items=items, total=total, page=page, per_page=per_page, @@ -161,6 +186,42 @@ def get_user_statistics( return stats +@admin_platform_users_router.get("/merchant-stats") +def get_merchant_user_statistics( + request: Request, + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Get merchant user statistics for admin dashboard. + + Uses the stats_aggregator to get merchant user metrics from the tenancy + module's MetricsProvider. + """ + platform_id = _get_platform_id(request, current_admin) + + metrics = stats_aggregator.get_admin_dashboard_stats(db=db, platform_id=platform_id) + tenancy_metrics = metrics.get("tenancy", []) + + stats = { + "merchant_users_total": 0, + "merchant_users_active": 0, + "merchant_owners": 0, + "merchant_team_members": 0, + } + + for metric in tenancy_metrics: + if metric.key == "tenancy.merchant_users_total": + stats["merchant_users_total"] = int(metric.value) + elif metric.key == "tenancy.merchant_users_active": + stats["merchant_users_active"] = int(metric.value) + elif metric.key == "tenancy.merchant_owners": + stats["merchant_owners"] = int(metric.value) + elif metric.key == "tenancy.merchant_team_members": + stats["merchant_team_members"] = int(metric.value) + + return stats + + @admin_platform_users_router.get("/search", response_model=UserSearchResponse) def search_users( q: str = Query(..., min_length=2, description="Search query (username or email)"), diff --git a/app/modules/tenancy/routes/pages/admin.py b/app/modules/tenancy/routes/pages/admin.py index 0299171c..52a91eef 100644 --- a/app/modules/tenancy/routes/pages/admin.py +++ b/app/modules/tenancy/routes/pages/admin.py @@ -243,6 +243,30 @@ async def admin_store_theme_page( ) +# ============================================================================ +# MERCHANT USER MANAGEMENT ROUTES (All Admins) +# ============================================================================ + + +@router.get("/merchant-users", response_class=HTMLResponse, include_in_schema=False) +async def admin_merchant_users_list_page( + request: Request, + current_user: User = Depends( + require_menu_access("merchant-users", FrontendType.ADMIN) + ), + db: Session = Depends(get_db), +): + """ + Render merchant users management page. + Shows list of all merchant users (owners and store team members). + Visible to all admins. + """ + return templates.TemplateResponse( + "tenancy/admin/merchant-users.html", + get_admin_context(request, db, current_user), + ) + + # ============================================================================ # ADMIN USER MANAGEMENT ROUTES (Super Admin Only) # ============================================================================ diff --git a/app/modules/tenancy/services/admin_service.py b/app/modules/tenancy/services/admin_service.py index 7fb5a521..c23f784b 100644 --- a/app/modules/tenancy/services/admin_service.py +++ b/app/modules/tenancy/services/admin_service.py @@ -107,18 +107,31 @@ class AdminService: per_page: int = 10, search: str | None = None, role: str | None = None, + scope: str | None = None, is_active: bool | None = None, ) -> tuple[list[User], int, int]: """ Get paginated list of users with filtering. + Args: + scope: Optional scope filter. 'merchant' returns users who are + merchant owners or store team members. + Returns: Tuple of (users, total_count, total_pages) """ import math + from app.modules.tenancy.models import Merchant, StoreUser + query = db.query(User) + # Apply scope filter + if scope == "merchant": + owner_ids = db.query(Merchant.owner_user_id).distinct() + team_ids = db.query(StoreUser.user_id).distinct() + query = query.filter(User.id.in_(owner_ids.union(team_ids))) + # Apply filters if search: search_term = f"%{search.lower()}%" diff --git a/app/modules/tenancy/services/tenancy_metrics.py b/app/modules/tenancy/services/tenancy_metrics.py index 2511e8e7..0170b4a4 100644 --- a/app/modules/tenancy/services/tenancy_metrics.py +++ b/app/modules/tenancy/services/tenancy_metrics.py @@ -12,6 +12,7 @@ Provides metrics for: import logging from typing import TYPE_CHECKING +from sqlalchemy import func from sqlalchemy.orm import Session from app.modules.contracts.metrics import ( @@ -123,7 +124,7 @@ class TenancyMetricsProvider: - Total users - Active users """ - from app.modules.tenancy.models import AdminPlatform, User, Store, StorePlatform + from app.modules.tenancy.models import AdminPlatform, Merchant, StoreUser, User, Store, StorePlatform try: # Store metrics - using StorePlatform junction table @@ -216,6 +217,40 @@ class TenancyMetricsProvider: inactive_users = total_users - active_users + # Merchant user metrics + # Owners: distinct users who own a merchant + merchant_owners = ( + db.query(func.count(func.distinct(Merchant.owner_user_id))).scalar() or 0 + ) + + # Team members: distinct StoreUser users who are NOT merchant owners + # Uses user_type="member" AND excludes owner user IDs to avoid overlap + team_members = ( + db.query(func.count(func.distinct(StoreUser.user_id))) + .filter( + StoreUser.user_type == "member", + ~StoreUser.user_id.in_(db.query(Merchant.owner_user_id)), + ) + .scalar() or 0 + ) + + # Total: union of both sets (deduplicated) + owner_ids = db.query(Merchant.owner_user_id).distinct() + team_ids = db.query(StoreUser.user_id).distinct() + merchant_users_total = ( + db.query(func.count(func.distinct(User.id))) + .filter(User.id.in_(owner_ids.union(team_ids))) + .scalar() or 0 + ) + merchant_users_active = ( + db.query(func.count(func.distinct(User.id))) + .filter( + User.id.in_(owner_ids.union(team_ids)), + User.is_active == True, + ) + .scalar() or 0 + ) + # Calculate rates verification_rate = ( (verified_stores / total_stores * 100) if total_stores > 0 else 0 @@ -317,6 +352,39 @@ class TenancyMetricsProvider: unit="%", description="Percentage of users that are active", ), + # Merchant user metrics + MetricValue( + key="tenancy.merchant_users_total", + value=merchant_users_total, + label="Total Merchant Users", + category="tenancy", + icon="users", + description="Total merchant-related users (owners and team members)", + ), + MetricValue( + key="tenancy.merchant_users_active", + value=merchant_users_active, + label="Active Merchant Users", + category="tenancy", + icon="user-check", + description="Active merchant users", + ), + MetricValue( + key="tenancy.merchant_owners", + value=merchant_owners, + label="Merchant Owners", + category="tenancy", + icon="briefcase", + description="Distinct merchant owners", + ), + MetricValue( + key="tenancy.merchant_team_members", + value=team_members, + label="Team Members", + category="tenancy", + icon="user-group", + description="Distinct store team members", + ), ] except Exception as e: logger.warning(f"Failed to get tenancy platform metrics: {e}") diff --git a/app/modules/tenancy/static/admin/js/merchant-users.js b/app/modules/tenancy/static/admin/js/merchant-users.js new file mode 100644 index 00000000..4a6c2dc9 --- /dev/null +++ b/app/modules/tenancy/static/admin/js/merchant-users.js @@ -0,0 +1,230 @@ +// noqa: js-006 - async init pattern is safe, loadData has try/catch +// static/admin/js/merchant-users.js + +// Create custom logger for merchant users +const merchantUsersLog = window.LogConfig.createLogger('MERCHANT-USERS'); + +function merchantUsersPage() { + return { + // Inherit base layout functionality + ...data(), + + // Set page identifier + currentPage: 'merchant-users', + + // State + merchantUsers: [], + loading: false, + error: null, + filters: { + search: '', + is_active: '' + }, + stats: { + merchant_users_total: 0, + merchant_owners: 0, + merchant_team_members: 0, + merchant_users_active: 0 + }, + pagination: { + page: 1, + per_page: 20, + total: 0, + pages: 0 + }, + + // Initialization + async init() { + // Load i18n translations + await I18n.loadModule('tenancy'); + + merchantUsersLog.info('=== MERCHANT USERS PAGE INITIALIZING ==='); + + // Prevent multiple initializations + if (window._merchantUsersInitialized) { + merchantUsersLog.warn('Merchant users page already initialized, skipping...'); + return; + } + window._merchantUsersInitialized = true; + + // Load platform settings for rows per page + if (window.PlatformSettings) { + this.pagination.per_page = await window.PlatformSettings.getRowsPerPage(); + } + + await this.loadMerchantUsers(); + await this.loadStats(); + + merchantUsersLog.info('=== MERCHANT USERS PAGE INITIALIZATION COMPLETE ==='); + }, + + // Format date helper + formatDate(dateString) { + if (!dateString) return '-'; + return Utils.formatDate(dateString); + }, + + // Computed: Total number of pages + get totalPages() { + return this.pagination.pages; + }, + + // Computed: Start index for pagination display + get startIndex() { + if (this.pagination.total === 0) return 0; + return (this.pagination.page - 1) * this.pagination.per_page + 1; + }, + + // Computed: End index for pagination display + get endIndex() { + const end = this.pagination.page * this.pagination.per_page; + return end > this.pagination.total ? this.pagination.total : end; + }, + + // Computed: Generate page numbers array with ellipsis + get pageNumbers() { + const pages = []; + const totalPages = this.totalPages; + const current = this.pagination.page; + + if (totalPages <= 7) { + for (let i = 1; i <= totalPages; i++) { + pages.push(i); + } + } else { + pages.push(1); + + if (current > 3) { + pages.push('...'); + } + + const start = Math.max(2, current - 1); + const end = Math.min(totalPages - 1, current + 1); + + for (let i = start; i <= end; i++) { + pages.push(i); + } + + if (current < totalPages - 2) { + pages.push('...'); + } + + pages.push(totalPages); + } + + return pages; + }, + + // Load merchant users from API + async loadMerchantUsers() { + merchantUsersLog.info('Loading merchant users...'); + this.loading = true; + this.error = null; + + try { + const params = new URLSearchParams(); + params.append('page', this.pagination.page); + params.append('per_page', this.pagination.per_page); + params.append('scope', 'merchant'); + + if (this.filters.search) { + params.append('search', this.filters.search); + } + if (this.filters.is_active !== '') { + params.append('is_active', this.filters.is_active); + } + + const url = `/admin/users?${params}`; + window.LogConfig.logApiCall('GET', url, null, 'request'); + + const startTime = performance.now(); + const response = await apiClient.get(url); + const duration = performance.now() - startTime; + + window.LogConfig.logApiCall('GET', url, response, 'response'); + window.LogConfig.logPerformance('Load Merchant Users', duration); + + this.merchantUsers = (response.items || []).map(user => ({ + ...user, + full_name: [user.first_name, user.last_name].filter(Boolean).join(' ') || null + })); + + this.pagination.total = response.total || 0; + this.pagination.pages = response.pages || Math.ceil(this.pagination.total / this.pagination.per_page) || 1; + + merchantUsersLog.info(`Loaded ${this.merchantUsers.length} merchant users`); + } catch (error) { + window.LogConfig.logError(error, 'Load Merchant Users'); + this.error = error.message || 'Failed to load merchant users'; + Utils.showToast('Failed to load merchant users', 'error'); + } finally { + this.loading = false; + } + }, + + // Load statistics from metrics provider + async loadStats() { + merchantUsersLog.info('Loading merchant user statistics...'); + + try { + const url = '/admin/users/merchant-stats'; + window.LogConfig.logApiCall('GET', url, null, 'request'); + + const response = await apiClient.get(url); + + window.LogConfig.logApiCall('GET', url, response, 'response'); + + this.stats = { + merchant_users_total: response.merchant_users_total || 0, + merchant_owners: response.merchant_owners || 0, + merchant_team_members: response.merchant_team_members || 0, + merchant_users_active: response.merchant_users_active || 0 + }; + + merchantUsersLog.debug('Stats loaded:', this.stats); + } catch (error) { + window.LogConfig.logError(error, 'Load Merchant Stats'); + // Stats are non-critical, don't show error toast + } + }, + + // Search with debounce + debouncedSearch() { + if (this._searchTimeout) { + clearTimeout(this._searchTimeout); + } + this._searchTimeout = setTimeout(() => { + merchantUsersLog.info('Search triggered:', this.filters.search); + this.pagination.page = 1; + this.loadMerchantUsers(); + }, 300); + }, + + // Pagination + nextPage() { + if (this.pagination.page < this.pagination.pages) { + this.pagination.page++; + merchantUsersLog.info('Next page:', this.pagination.page); + this.loadMerchantUsers(); + } + }, + + previousPage() { + if (this.pagination.page > 1) { + this.pagination.page--; + merchantUsersLog.info('Previous page:', this.pagination.page); + this.loadMerchantUsers(); + } + }, + + goToPage(pageNum) { + if (pageNum !== '...' && pageNum >= 1 && pageNum <= this.totalPages) { + this.pagination.page = pageNum; + merchantUsersLog.info('Go to page:', this.pagination.page); + this.loadMerchantUsers(); + } + } + }; +} + +merchantUsersLog.info('Merchant users module loaded'); diff --git a/app/modules/tenancy/templates/tenancy/admin/merchant-users.html b/app/modules/tenancy/templates/tenancy/admin/merchant-users.html new file mode 100644 index 00000000..d994c0b5 --- /dev/null +++ b/app/modules/tenancy/templates/tenancy/admin/merchant-users.html @@ -0,0 +1,211 @@ +{# app/templates/admin/merchant-users.html #} +{% extends "admin/base.html" %} +{% from 'shared/macros/pagination.html' import pagination %} +{% from 'shared/macros/headers.html' import page_header %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} +{% from 'shared/macros/tables.html' import table_wrapper, table_header %} + +{% block title %}Merchant Users{% endblock %} + +{% block alpine_data %}merchantUsersPage(){% endblock %} + +{% block content %} +{{ page_header('Merchant User Management', subtitle='View merchant owners and store team members') }} + +{{ loading_state('Loading merchant users...') }} + +{{ error_state('Error loading merchant users') }} + + +
+ Total Merchant Users +
++ 0 +
++ Owners +
++ 0 +
++ Team Members +
++ 0 +
++ Active +
++ 0 +
+No merchant users found
+ +