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

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