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:
@@ -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
|
||||
# =========================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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()
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"menu": {
|
||||
"user_management": "Benutzerverwaltung",
|
||||
"merchant_users": "Händler-Benutzer"
|
||||
},
|
||||
"team": {
|
||||
"title": "Team",
|
||||
"members": "Mitglieder",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"menu": {
|
||||
"user_management": "User Management",
|
||||
"merchant_users": "Merchant Users"
|
||||
},
|
||||
"team": {
|
||||
"title": "Team",
|
||||
"members": "Members",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"menu": {
|
||||
"user_management": "Gestion des utilisateurs",
|
||||
"merchant_users": "Utilisateurs marchands"
|
||||
},
|
||||
"team": {
|
||||
"title": "Équipe",
|
||||
"members": "Membres",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"menu": {
|
||||
"user_management": "Benotzerverwaltung",
|
||||
"merchant_users": "Händler-Benotzer"
|
||||
},
|
||||
"team": {
|
||||
"title": "Team",
|
||||
"members": "Memberen",
|
||||
|
||||
@@ -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)"),
|
||||
|
||||
@@ -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)
|
||||
# ============================================================================
|
||||
|
||||
@@ -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()}%"
|
||||
|
||||
@@ -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}")
|
||||
|
||||
230
app/modules/tenancy/static/admin/js/merchant-users.js
Normal file
230
app/modules/tenancy/static/admin/js/merchant-users.js
Normal file
@@ -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');
|
||||
211
app/modules/tenancy/templates/tenancy/admin/merchant-users.html
Normal file
211
app/modules/tenancy/templates/tenancy/admin/merchant-users.html
Normal file
@@ -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') }}
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
|
||||
<!-- Card: Total Merchant Users -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
|
||||
<span x-html="$icon('users', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Total Merchant Users
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.merchant_users_total || 0">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card: Merchant Owners -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
|
||||
<span x-html="$icon('briefcase', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Owners
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.merchant_owners || 0">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card: Team Members -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
|
||||
<span x-html="$icon('user-group', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Team Members
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.merchant_team_members || 0">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card: Active -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
|
||||
<span x-html="$icon('user-check', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Active
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.merchant_users_active || 0">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filters -->
|
||||
<div x-show="!loading" class="mb-6 px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<!-- Search Bar -->
|
||||
<div class="flex-1 max-w-md">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
x-model="filters.search"
|
||||
@input="debouncedSearch()"
|
||||
placeholder="Search by name or email..."
|
||||
class="w-full pl-10 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:bg-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<!-- Status Filter -->
|
||||
<select
|
||||
x-model="filters.is_active"
|
||||
@change="pagination.page = 1; loadMerchantUsers()"
|
||||
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="true">Active</option>
|
||||
<option value="false">Inactive</option>
|
||||
</select>
|
||||
|
||||
<!-- Refresh Button -->
|
||||
<button
|
||||
@click="loadMerchantUsers(); loadStats()"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none transition-colors"
|
||||
title="Refresh merchant users"
|
||||
>
|
||||
<span x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Merchant Users Table -->
|
||||
<div x-show="!loading">
|
||||
{% call table_wrapper() %}
|
||||
{{ table_header(['User', 'Email', 'Role', 'Status', 'Last Login', 'Actions']) }}
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<!-- Empty State -->
|
||||
<template x-if="merchantUsers.length === 0">
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-8 text-center text-gray-600 dark:text-gray-400">
|
||||
<div class="flex flex-col items-center">
|
||||
<span x-html="$icon('users', 'w-12 h-12 text-gray-400 mb-4')"></span>
|
||||
<p class="text-lg font-medium">No merchant users found</p>
|
||||
<p class="text-sm" x-text="filters.search ? 'Try adjusting your search or filters' : 'Merchant users will appear here when merchants are created'"></p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<!-- Merchant User Rows -->
|
||||
<template x-for="user in merchantUsers" :key="user.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<!-- User Info -->
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center text-sm">
|
||||
<div class="relative hidden w-8 h-8 mr-3 rounded-full md:block">
|
||||
<div class="absolute inset-0 rounded-full flex items-center justify-center text-white font-semibold text-sm bg-blue-500"
|
||||
x-text="(user.username || user.email || 'U').charAt(0).toUpperCase()">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold" x-text="user.full_name || user.username || user.email"></p>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="user.username || ''"></p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Email -->
|
||||
<td class="px-4 py-3 text-sm" x-text="user.email"></td>
|
||||
|
||||
<!-- Role -->
|
||||
<td class="px-4 py-3 text-xs">
|
||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full"
|
||||
:class="user.owned_merchants_count > 0
|
||||
? 'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100'
|
||||
: 'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100'"
|
||||
x-text="user.owned_merchants_count > 0 ? 'Owner' : 'Team Member'">
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<!-- Status -->
|
||||
<td class="px-4 py-3 text-xs">
|
||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full"
|
||||
:class="user.is_active ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100'"
|
||||
x-text="user.is_active ? 'Active' : 'Inactive'">
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<!-- Last Login -->
|
||||
<td class="px-4 py-3 text-sm" x-text="user.last_login ? formatDate(user.last_login) : 'Never'"></td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center space-x-2 text-sm">
|
||||
<!-- View Button -->
|
||||
<a
|
||||
:href="'/admin/admin-users/' + user.id"
|
||||
class="flex items-center justify-center p-2 text-blue-600 rounded-lg hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
|
||||
title="View user details"
|
||||
>
|
||||
<span x-html="$icon('eye', 'w-5 h-5')"></span>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
{% endcall %}
|
||||
|
||||
{{ pagination() }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="{{ url_for('tenancy_static', path='admin/js/merchant-users.js') }}"></script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user