feat: consolidate media service, add merchant users page, fix metrics overlap

- Merge ImageService into MediaService with WebP variant generation,
  DB-backed storage stats, and module-driven media usage discovery
  via new MediaUsageProviderProtocol
- Add merchant users admin page with scoped user listing, stats
  endpoint, template, JS, and i18n strings (de/en/fr/lb)
- Fix merchant user metrics so Owners and Team Members are mutually
  exclusive (filter team_members on user_type="member" and exclude
  owner IDs) ensuring stat cards add up correctly
- Update billing and monitoring services to use media_service
- Update subscription-billing and feature-gating docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 21:17:11 +01:00
parent 4cb2bda575
commit 2250054ba2
30 changed files with 1220 additions and 805 deletions

View File

@@ -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
# =========================================================================

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",

View File

@@ -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):
"""

View File

@@ -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",

View File

@@ -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()

View File

@@ -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."""

View File

@@ -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)

View File

@@ -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,
),
],
),

View File

@@ -1,4 +1,8 @@
{
"menu": {
"user_management": "Benutzerverwaltung",
"merchant_users": "Händler-Benutzer"
},
"team": {
"title": "Team",
"members": "Mitglieder",

View File

@@ -1,4 +1,8 @@
{
"menu": {
"user_management": "User Management",
"merchant_users": "Merchant Users"
},
"team": {
"title": "Team",
"members": "Members",

View File

@@ -1,4 +1,8 @@
{
"menu": {
"user_management": "Gestion des utilisateurs",
"merchant_users": "Utilisateurs marchands"
},
"team": {
"title": "Équipe",
"members": "Membres",

View File

@@ -1,4 +1,8 @@
{
"menu": {
"user_management": "Benotzerverwaltung",
"merchant_users": "Händler-Benotzer"
},
"team": {
"title": "Team",
"members": "Memberen",

View File

@@ -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)"),

View File

@@ -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)
# ============================================================================

View File

@@ -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()}%"

View File

@@ -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}")

View 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');

View 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 %}