refactor: fix architecture violations with provider patterns and dependency inversion

Major changes:
- Add AuditProvider protocol for cross-module audit logging
- Move customer order operations to orders module (dependency inversion)
- Add customer order metrics via MetricsProvider pattern
- Fix missing db parameter in get_admin_context() calls
- Move ProductMedia relationship to catalog module (proper ownership)
- Add marketplace breakdown stats to marketplace_widgets

New files:
- contracts/audit.py - AuditProviderProtocol
- core/services/audit_aggregator.py - Aggregates audit providers
- monitoring/services/audit_provider.py - Monitoring audit implementation
- orders/services/customer_order_service.py - Customer order operations
- orders/routes/api/vendor_customer_orders.py - Customer order endpoints
- catalog/services/product_media_service.py - Product media service
- Architecture documentation for patterns

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-04 21:32:32 +01:00
parent bd43e21940
commit 39dff4ab7d
34 changed files with 2751 additions and 407 deletions

View File

@@ -83,5 +83,5 @@ async def admin_code_quality_violation_detail(
"""
return templates.TemplateResponse(
"dev_tools/admin/code-quality-violation-detail.html",
get_admin_context(request, current_user, violation_id=violation_id),
get_admin_context(request, db, current_user, violation_id=violation_id),
)

View File

@@ -44,6 +44,7 @@ if TYPE_CHECKING:
from fastapi import APIRouter
from pydantic import BaseModel
from app.modules.contracts.audit import AuditProviderProtocol
from app.modules.contracts.metrics import MetricsProviderProtocol
from app.modules.contracts.widgets import DashboardWidgetProviderProtocol
@@ -434,6 +435,26 @@ class ModuleDefinition:
# The provider will be discovered by core's WidgetAggregator service.
widget_provider: "Callable[[], DashboardWidgetProviderProtocol] | None" = None
# =========================================================================
# Audit Provider (Module-Driven Audit Logging)
# =========================================================================
# Callable that returns an AuditProviderProtocol implementation.
# Use a callable (factory function) to enable lazy loading and avoid
# circular imports. Modules can provide audit logging backends.
#
# Example:
# def _get_audit_provider():
# from app.modules.monitoring.services.audit_provider import audit_provider
# return audit_provider
#
# monitoring_module = ModuleDefinition(
# code="monitoring",
# audit_provider=_get_audit_provider,
# )
#
# The provider will be discovered by core's AuditAggregator service.
audit_provider: "Callable[[], AuditProviderProtocol] | None" = None
# =========================================================================
# Menu Item Methods (Legacy - uses menu_items dict of IDs)
# =========================================================================
@@ -842,6 +863,28 @@ class ModuleDefinition:
return None
return self.widget_provider()
# =========================================================================
# Audit Provider Methods
# =========================================================================
def has_audit_provider(self) -> bool:
"""Check if this module has an audit provider."""
return self.audit_provider is not None
def get_audit_provider_instance(self) -> "AuditProviderProtocol | None":
"""
Get the audit provider instance for this module.
Calls the audit_provider factory function to get the provider.
Returns None if no provider is configured.
Returns:
AuditProviderProtocol instance, or None
"""
if self.audit_provider is None:
return None
return self.audit_provider()
# =========================================================================
# Magic Methods
# =========================================================================

View File

@@ -9,11 +9,11 @@ Usage:
"""
from app.modules.catalog.models.product import Product
from app.modules.catalog.models.product_translation import ProductTranslation
from app.modules.catalog.models.product_media import ProductMedia
from app.modules.catalog.models.product_translation import ProductTranslation
__all__ = [
"Product",
"ProductTranslation",
"ProductMedia",
"ProductTranslation",
]

View File

@@ -114,6 +114,9 @@ class Product(Base, TimestampMixin):
inventory_entries = relationship(
"Inventory", back_populates="product", cascade="all, delete-orphan"
)
media_associations = relationship(
"ProductMedia", back_populates="product", cascade="all, delete-orphan"
)
# === CONSTRAINTS & INDEXES ===
__table_args__ = (

View File

@@ -4,6 +4,11 @@ Product-Media association model.
Links media files to products with usage type tracking
(main image, gallery, variant images, etc.)
This model lives in the catalog module because:
- It's an association specific to products
- Catalog owns the relationship between its products and media
- CMS provides generic media storage, catalog defines how it uses media
"""
from sqlalchemy import (
@@ -25,6 +30,8 @@ class ProductMedia(Base, TimestampMixin):
Tracks which media files are used by which products,
including the usage type (main image, gallery, variant, etc.)
This is catalog's association table - CMS doesn't need to know about it.
"""
__tablename__ = "product_media"
@@ -52,8 +59,9 @@ class ProductMedia(Base, TimestampMixin):
variant_id = Column(Integer) # Reference to variant if applicable
# Relationships
product = relationship("Product")
media = relationship("MediaFile", back_populates="product_associations")
product = relationship("Product", back_populates="media_associations")
# MediaFile is generic and doesn't know about ProductMedia
media = relationship("MediaFile", lazy="joined")
__table_args__ = (
UniqueConstraint(

View File

@@ -0,0 +1,280 @@
# app/modules/catalog/services/product_media_service.py
"""
Product media service for managing product-media associations.
This service handles the catalog-specific logic for attaching and
detaching media files to products. It uses the generic MediaFile
from CMS but owns the ProductMedia association.
This follows the principle that:
- CMS provides generic media storage (agnostic to consumers)
- Catalog defines how it uses media (ProductMedia association)
"""
import logging
from sqlalchemy.orm import Session
from app.modules.catalog.models import Product, ProductMedia
from app.modules.cms.models import MediaFile
logger = logging.getLogger(__name__)
class ProductMediaService:
"""Service for product-media association operations."""
def attach_media_to_product(
self,
db: Session,
vendor_id: int,
product_id: int,
media_id: int,
usage_type: str = "gallery",
display_order: int = 0,
) -> ProductMedia | None:
"""
Attach a media file to a product.
Args:
db: Database session
vendor_id: Vendor ID (for ownership verification)
product_id: Product ID
media_id: Media file ID
usage_type: How the media is used (main_image, gallery, etc.)
display_order: Order for galleries
Returns:
Created or updated ProductMedia association
Raises:
ValueError: If product or media doesn't belong to vendor
"""
# Verify product belongs to vendor
product = (
db.query(Product)
.filter(Product.id == product_id, Product.vendor_id == vendor_id)
.first()
)
if not product:
raise ValueError(f"Product {product_id} not found for vendor {vendor_id}")
# Verify media belongs to vendor
media = (
db.query(MediaFile)
.filter(MediaFile.id == media_id, MediaFile.vendor_id == vendor_id)
.first()
)
if not media:
raise ValueError(f"Media {media_id} not found for vendor {vendor_id}")
# Check if already attached with same usage type
existing = (
db.query(ProductMedia)
.filter(
ProductMedia.product_id == product_id,
ProductMedia.media_id == media_id,
ProductMedia.usage_type == usage_type,
)
.first()
)
if existing:
# Update display order if association exists
existing.display_order = display_order
db.flush()
return existing
# Create new association
product_media = ProductMedia(
product_id=product_id,
media_id=media_id,
usage_type=usage_type,
display_order=display_order,
)
db.add(product_media)
# Update usage count on media
media.usage_count = (media.usage_count or 0) + 1
db.flush()
logger.info(
f"Attached media {media_id} to product {product_id} as {usage_type}"
)
return product_media
def detach_media_from_product(
self,
db: Session,
vendor_id: int,
product_id: int,
media_id: int,
usage_type: str | None = None,
) -> int:
"""
Detach a media file from a product.
Args:
db: Database session
vendor_id: Vendor ID (for ownership verification)
product_id: Product ID
media_id: Media file ID
usage_type: Specific usage type to remove (None = all usages)
Returns:
Number of associations removed
Raises:
ValueError: If product doesn't belong to vendor
"""
# Verify product belongs to vendor
product = (
db.query(Product)
.filter(Product.id == product_id, Product.vendor_id == vendor_id)
.first()
)
if not product:
raise ValueError(f"Product {product_id} not found for vendor {vendor_id}")
# Build query
query = db.query(ProductMedia).filter(
ProductMedia.product_id == product_id,
ProductMedia.media_id == media_id,
)
if usage_type:
query = query.filter(ProductMedia.usage_type == usage_type)
deleted_count = query.delete()
# Update usage count on media
if deleted_count > 0:
media = db.query(MediaFile).filter(MediaFile.id == media_id).first()
if media:
media.usage_count = max(0, (media.usage_count or 0) - deleted_count)
db.flush()
logger.info(
f"Detached {deleted_count} media association(s) from product {product_id}"
)
return deleted_count
def get_product_media(
self,
db: Session,
product_id: int,
usage_type: str | None = None,
) -> list[ProductMedia]:
"""
Get media associations for a product.
Args:
db: Database session
product_id: Product ID
usage_type: Filter by usage type (None = all)
Returns:
List of ProductMedia associations
"""
query = (
db.query(ProductMedia)
.filter(ProductMedia.product_id == product_id)
.order_by(ProductMedia.display_order)
)
if usage_type:
query = query.filter(ProductMedia.usage_type == usage_type)
return query.all()
def get_media_usage_for_product(
self,
db: Session,
product_id: int,
) -> dict:
"""
Get all media usage information for a product.
Args:
db: Database session
product_id: Product ID
Returns:
Dict with media organized by usage type
"""
associations = self.get_product_media(db, product_id)
usage_by_type: dict[str, list] = {}
for assoc in associations:
usage_type = assoc.usage_type
if usage_type not in usage_by_type:
usage_by_type[usage_type] = []
media = assoc.media
usage_by_type[usage_type].append({
"association_id": assoc.id,
"media_id": media.id if media else None,
"filename": media.filename if media else None,
"file_url": media.file_url if media else None,
"thumbnail_url": media.thumbnail_url if media else None,
"display_order": assoc.display_order,
})
return {
"product_id": product_id,
"media_by_type": usage_by_type,
"total_count": len(associations),
}
def set_main_image(
self,
db: Session,
vendor_id: int,
product_id: int,
media_id: int,
) -> ProductMedia | None:
"""
Set the main image for a product.
Removes any existing main_image association and creates a new one.
Args:
db: Database session
vendor_id: Vendor ID
product_id: Product ID
media_id: Media file ID to set as main image
Returns:
The new ProductMedia association
"""
# Remove existing main image
self.detach_media_from_product(
db, vendor_id, product_id, media_id=0, usage_type="main_image"
)
# Actually, we need to remove ALL main_image associations, not just for media_id=0
db.query(ProductMedia).filter(
ProductMedia.product_id == product_id,
ProductMedia.usage_type == "main_image",
).delete()
# Attach new main image
return self.attach_media_to_product(
db,
vendor_id=vendor_id,
product_id=product_id,
media_id=media_id,
usage_type="main_image",
display_order=0,
)
# Singleton instance
product_media_service = ProductMediaService()
__all__ = ["ProductMediaService", "product_media_service"]

View File

@@ -4,14 +4,15 @@ CMS module database models.
This is the canonical location for CMS models including:
- ContentPage: CMS pages (marketing, vendor default pages)
- MediaFile: Vendor media library
- MediaFile: Vendor media library (generic, consumer-agnostic)
- VendorTheme: Vendor storefront theme configuration
Usage:
from app.modules.cms.models import ContentPage, MediaFile, VendorTheme
For product-media associations:
from app.modules.catalog.models import ProductMedia
Note: ProductMedia is in the catalog module since it's catalog's association
to media files. CMS provides generic media storage, consumers define their
own associations.
"""
from app.modules.cms.models.content_page import ContentPage

View File

@@ -1,11 +1,15 @@
# app/modules/cms/models/media.py
"""
CORE media file model for vendor media library.
Generic media file model for vendor media library.
This is a CORE framework model used across multiple modules.
MediaFile provides vendor-uploaded media files (images, documents, videos).
This is a consumer-agnostic media storage model. MediaFile provides
vendor-uploaded media files (images, documents, videos) without knowing
what entities will use them.
For product-media associations, use:
Modules that need media (catalog, art-gallery, etc.) define their own
association tables that reference MediaFile.
For product-media associations:
from app.modules.catalog.models import ProductMedia
Files are stored in vendor-specific directories:
@@ -73,12 +77,8 @@ class MediaFile(Base, TimestampMixin):
# Relationships
vendor = relationship("Vendor", back_populates="media_files")
# ProductMedia relationship uses string reference to avoid circular import
product_associations = relationship(
"ProductMedia",
back_populates="media",
cascade="all, delete-orphan",
)
# Note: Consumer-specific associations (ProductMedia, etc.) are defined
# in their respective modules. CMS doesn't know about specific consumers.
__table_args__ = (
Index("idx_media_vendor_id", "vendor_id"),

View File

@@ -16,14 +16,11 @@ import shutil
import uuid
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any
from typing import Any
from sqlalchemy import or_
from sqlalchemy.orm import Session
if TYPE_CHECKING:
from app.modules.catalog.models import ProductMedia
from app.modules.cms.exceptions import (
MediaNotFoundException,
MediaUploadException,
@@ -420,147 +417,9 @@ class MediaService:
return True
def get_media_usage(
self, db: Session, vendor_id: int, media_id: int
) -> dict:
"""
Get where a media file is being used.
Returns:
Dict with products and other usage information
"""
media = self.get_media(db, vendor_id, media_id)
# Get product associations
product_usage = []
for assoc in media.product_associations:
product = assoc.product
if product:
product_usage.append({
"product_id": product.id,
"product_name": product.get_title() or f"Product {product.id}",
"usage_type": assoc.usage_type,
})
return {
"media_id": media_id,
"products": product_usage,
"other_usage": [],
"total_usage_count": len(product_usage),
}
def attach_to_product(
self,
db: Session,
vendor_id: int,
media_id: int,
product_id: int,
usage_type: str = "gallery",
display_order: int = 0,
) -> "ProductMedia | None":
"""
Attach a media file to a product.
Args:
db: Database session
vendor_id: Vendor ID
media_id: Media file ID
product_id: Product ID
usage_type: How the media is used (main_image, gallery, etc.)
display_order: Order for galleries
Returns:
Created ProductMedia association, or None if catalog module unavailable
"""
try:
from app.modules.catalog.models import ProductMedia
except ImportError:
logger.warning("Catalog module not available for media-product attachment")
return None
# Verify media belongs to vendor
media = self.get_media(db, vendor_id, media_id)
# Check if already attached with same usage type
existing = (
db.query(ProductMedia)
.filter(
ProductMedia.product_id == product_id,
ProductMedia.media_id == media_id,
ProductMedia.usage_type == usage_type,
)
.first()
)
if existing:
existing.display_order = display_order
db.flush()
return existing
# Create association
product_media = ProductMedia(
product_id=product_id,
media_id=media_id,
usage_type=usage_type,
display_order=display_order,
)
db.add(product_media)
# Update usage count
media.usage_count = (media.usage_count or 0) + 1
db.flush()
return product_media
def detach_from_product(
self,
db: Session,
vendor_id: int,
media_id: int,
product_id: int,
usage_type: str | None = None,
) -> bool:
"""
Detach a media file from a product.
Args:
db: Database session
vendor_id: Vendor ID
media_id: Media file ID
product_id: Product ID
usage_type: Specific usage type to remove (None = all)
Returns:
True if detached, False if catalog module unavailable
"""
try:
from app.modules.catalog.models import ProductMedia
except ImportError:
logger.warning("Catalog module not available for media-product detachment")
return False
# Verify media belongs to vendor
media = self.get_media(db, vendor_id, media_id)
query = db.query(ProductMedia).filter(
ProductMedia.product_id == product_id,
ProductMedia.media_id == media_id,
)
if usage_type:
query = query.filter(ProductMedia.usage_type == usage_type)
deleted_count = query.delete()
# Update usage count
if deleted_count > 0:
media.usage_count = max(0, (media.usage_count or 0) - deleted_count)
db.flush()
return deleted_count > 0
# 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.
# Create service instance

View File

@@ -45,6 +45,10 @@ Widget Provider Pattern:
return [DashboardWidget(key="orders.recent", widget_type="list", ...)]
"""
from app.modules.contracts.audit import (
AuditEvent,
AuditProviderProtocol,
)
from app.modules.contracts.base import ServiceProtocol
from app.modules.contracts.cms import ContentServiceProtocol
from app.modules.contracts.metrics import (
@@ -67,6 +71,9 @@ __all__ = [
"ServiceProtocol",
# CMS protocols
"ContentServiceProtocol",
# Audit protocols
"AuditEvent",
"AuditProviderProtocol",
# Metrics protocols
"MetricValue",
"MetricsContext",

View File

@@ -0,0 +1,168 @@
# app/modules/contracts/audit.py
"""
Audit provider protocol for cross-module audit logging.
This module defines the protocol that modules implement to provide audit logging.
The core module's AuditAggregator discovers and uses providers from enabled modules.
Benefits:
- Audit logging is optional (monitoring module can be disabled)
- Each module can have its own audit implementation
- Core doesn't depend on monitoring module
- Easy to add different audit backends (file, database, external service)
Usage:
# 1. Implement the protocol in your module
class DatabaseAuditProvider:
@property
def audit_backend(self) -> str:
return "database"
def log_action(self, db, event: AuditEvent) -> bool:
# Log to database
...
# 2. Register in module definition
def _get_audit_provider():
from app.modules.monitoring.services.audit_provider import audit_provider
return audit_provider
monitoring_module = ModuleDefinition(
code="monitoring",
audit_provider=_get_audit_provider,
# ...
)
# 3. Use via aggregator in core
from app.modules.core.services.audit_aggregator import audit_aggregator
audit_aggregator.log_action(
db=db,
event=AuditEvent(
admin_user_id=123,
action="create_vendor",
target_type="vendor",
target_id="456",
)
)
"""
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
if TYPE_CHECKING:
from sqlalchemy.orm import Session
@dataclass
class AuditEvent:
"""
Standard audit event data.
This is the unit of data passed to audit providers.
It contains all information needed to log an admin action.
Attributes:
admin_user_id: ID of the admin performing the action
action: Action performed (e.g., "create_vendor", "update_setting")
target_type: Type of target (e.g., "vendor", "user", "setting")
target_id: ID of the target entity (as string)
details: Additional context about the action
ip_address: IP address of the admin (optional)
user_agent: User agent string (optional)
request_id: Request ID for correlation (optional)
Example:
AuditEvent(
admin_user_id=1,
action="create_setting",
target_type="setting",
target_id="max_vendors",
details={"category": "system", "value_type": "integer"},
)
"""
admin_user_id: int
action: str
target_type: str
target_id: str
details: dict[str, Any] | None = None
ip_address: str | None = None
user_agent: str | None = None
request_id: str | None = None
@runtime_checkable
class AuditProviderProtocol(Protocol):
"""
Protocol for modules that provide audit logging.
Each module can implement this to expose its own audit backend.
The core module's AuditAggregator discovers and uses providers.
Implementation Notes:
- Providers should be fault-tolerant (don't break operations on failure)
- Return True on success, False on failure
- Log errors internally but don't raise exceptions
- Be mindful of performance (audit logging should be fast)
Example Implementation:
class DatabaseAuditProvider:
@property
def audit_backend(self) -> str:
return "database"
def log_action(self, db: Session, event: AuditEvent) -> bool:
try:
from app.modules.tenancy.models import AdminAuditLog
audit_log = AdminAuditLog(
admin_user_id=event.admin_user_id,
action=event.action,
target_type=event.target_type,
target_id=event.target_id,
details=event.details or {},
ip_address=event.ip_address,
user_agent=event.user_agent,
request_id=event.request_id,
)
db.add(audit_log)
db.flush()
return True
except Exception:
return False
"""
@property
def audit_backend(self) -> str:
"""
Backend name for this provider.
Should be a short, lowercase identifier for the audit backend.
Examples: "database", "file", "elasticsearch", "cloudwatch"
Returns:
Backend string used for identification
"""
...
def log_action(self, db: "Session", event: AuditEvent) -> bool:
"""
Log an audit event.
Called by the audit aggregator to record admin actions.
Should be fault-tolerant - don't raise exceptions, just return False.
Args:
db: Database session (may be needed for database backends)
event: The audit event to log
Returns:
True if logged successfully, False otherwise
"""
...
__all__ = [
"AuditEvent",
"AuditProviderProtocol",
]

View File

@@ -20,7 +20,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.modules.contracts.widgets import ListWidget
from app.modules.contracts.widgets import BreakdownWidget, ListWidget
from app.modules.core.schemas.dashboard import (
AdminDashboardResponse,
ImportStatsResponse,
@@ -219,29 +219,37 @@ def get_comprehensive_stats(
"/stats/marketplace", response_model=list[MarketplaceStatsResponse]
)
def get_marketplace_stats(
request: Request,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get statistics broken down by marketplace (Admin only)."""
# For detailed marketplace breakdown, we still use the analytics service
# as the MetricsProvider pattern is for aggregated stats
try:
from app.modules.analytics.services.stats_service import stats_service
"""Get statistics broken down by marketplace (Admin only).
marketplace_stats = stats_service.get_marketplace_breakdown_stats(db=db)
return [
MarketplaceStatsResponse(
marketplace=stat["marketplace"],
total_products=stat["total_products"],
unique_vendors=stat["unique_vendors"],
unique_brands=stat["unique_brands"],
)
for stat in marketplace_stats
]
except ImportError:
# Analytics module not available
logger.warning("Analytics module not available for marketplace breakdown stats")
return []
Uses the widget_aggregator to get marketplace breakdown data from the
marketplace module's WidgetProvider, avoiding cross-module violations.
"""
platform_id = _get_platform_id(request, current_admin)
# Get widgets from marketplace module via aggregator
widgets = widget_aggregator.get_admin_dashboard_widgets(db=db, platform_id=platform_id)
# Extract marketplace breakdown widget
marketplace_widgets = widgets.get("marketplace", [])
for widget in marketplace_widgets:
if widget.key == "marketplace.breakdown" and isinstance(widget.data, BreakdownWidget):
# Transform breakdown items to MarketplaceStatsResponse
return [
MarketplaceStatsResponse(
marketplace=item.label,
total_products=int(item.value),
unique_vendors=int(item.secondary_value or 0),
unique_brands=0, # Not included in breakdown widget
)
for item in widget.data.items
]
# No breakdown widget found
return []
@admin_dashboard_router.get("/stats/platform", response_model=PlatformStatsResponse)

View File

@@ -20,8 +20,8 @@ from app.core.config import settings as app_settings
from app.core.database import get_db
from app.exceptions import ResourceNotFoundException
from app.modules.tenancy.exceptions import ConfirmationRequiredException
from app.modules.monitoring.services.admin_audit_service import admin_audit_service
from app.modules.core.services.admin_settings_service import admin_settings_service
from app.modules.core.services.audit_aggregator import audit_aggregator
from models.schema.auth import UserContext
from app.modules.tenancy.schemas.admin import (
AdminSettingCreate,
@@ -116,7 +116,7 @@ def create_setting(
)
# Log action
admin_audit_service.log_action(
audit_aggregator.log(
db=db,
admin_user_id=current_admin.id,
action="create_setting",
@@ -147,7 +147,7 @@ def update_setting(
)
# Log action
admin_audit_service.log_action(
audit_aggregator.log(
db=db,
admin_user_id=current_admin.id,
action="update_setting",
@@ -176,7 +176,7 @@ def upsert_setting(
)
# Log action
admin_audit_service.log_action(
audit_aggregator.log(
db=db,
admin_user_id=current_admin.id,
action="upsert_setting",
@@ -233,7 +233,7 @@ def set_rows_per_page(
db=db, setting_data=setting_data, admin_user_id=current_admin.id
)
admin_audit_service.log_action(
audit_aggregator.log(
db=db,
admin_user_id=current_admin.id,
action="update_setting",
@@ -288,7 +288,7 @@ def delete_setting(
)
# Log action
admin_audit_service.log_action(
audit_aggregator.log(
db=db,
admin_user_id=current_admin.id,
action="delete_setting",
@@ -586,7 +586,7 @@ def update_email_settings(
updated_keys.append(field)
# Log action
admin_audit_service.log_action(
audit_aggregator.log(
db=db,
admin_user_id=current_admin.id,
action="update_email_settings",
@@ -625,7 +625,7 @@ def reset_email_settings(
deleted_count += 1
# Log action
admin_audit_service.log_action(
audit_aggregator.log(
db=db,
admin_user_id=current_admin.id,
action="reset_email_settings",
@@ -688,7 +688,7 @@ def send_test_email(
# Check if email was actually sent (send_raw returns EmailLog, not boolean)
if email_log.status == "sent":
# Log action
admin_audit_service.log_action(
audit_aggregator.log(
db=db,
admin_user_id=current_admin.id,
action="send_test_email",

View File

@@ -0,0 +1,216 @@
# app/modules/core/services/audit_aggregator.py
"""
Audit aggregator service for collecting audit providers from all modules.
This service lives in core because audit logging is infrastructure functionality
that should be available across all modules. It discovers AuditProviders from
enabled modules, providing a unified interface for audit logging.
Benefits:
- Audit logging always works (aggregator is in core)
- Each module can provide its own audit backend
- Optional modules are truly optional (monitoring can be removed without breaking app)
- Easy to add new audit backends (just implement AuditProviderProtocol in your module)
Usage:
from app.modules.core.services.audit_aggregator import audit_aggregator
from app.modules.contracts.audit import AuditEvent
# Log an admin action
audit_aggregator.log_action(
db=db,
event=AuditEvent(
admin_user_id=123,
action="create_setting",
target_type="setting",
target_id="max_vendors",
details={"category": "system"},
)
)
# Or use the convenience method with individual parameters
audit_aggregator.log(
db=db,
admin_user_id=123,
action="create_setting",
target_type="setting",
target_id="max_vendors",
details={"category": "system"},
)
"""
import logging
from typing import TYPE_CHECKING, Any
from sqlalchemy.orm import Session
from app.modules.contracts.audit import (
AuditEvent,
AuditProviderProtocol,
)
if TYPE_CHECKING:
from app.modules.base import ModuleDefinition
logger = logging.getLogger(__name__)
class AuditAggregatorService:
"""
Aggregates audit providers from all modules.
This service discovers AuditProviders from enabled modules and provides
a unified interface for audit logging. It handles graceful degradation
when modules are disabled or providers fail.
"""
def _get_enabled_providers(
self, db: Session
) -> list[tuple["ModuleDefinition", AuditProviderProtocol]]:
"""
Get audit providers from enabled modules.
Note: Unlike metrics/widget providers, audit logging doesn't filter
by platform_id - it's a system-wide concern. We still need db to
check if optional modules are enabled.
Args:
db: Database session
Returns:
List of (module, provider) tuples for enabled modules with providers
"""
from app.modules.registry import MODULES
providers: list[tuple[ModuleDefinition, AuditProviderProtocol]] = []
for module in MODULES.values():
# Skip modules without audit providers
if not module.has_audit_provider():
continue
# Core and internal modules are always enabled
# For optional modules, we don't strictly check enablement for audit
# because audit logging is system infrastructure
# Get the provider instance
try:
provider = module.get_audit_provider_instance()
if provider is not None:
providers.append((module, provider))
except Exception as e:
logger.warning(
f"Failed to get audit provider for module {module.code}: {e}"
)
return providers
def log_action(self, db: Session, event: AuditEvent) -> bool:
"""
Log an audit event using all available providers.
Called by services/routes to record admin actions.
Uses all available audit providers - if multiple are configured,
all will receive the event (e.g., database + external service).
Args:
db: Database session
event: The audit event to log
Returns:
True if at least one provider logged successfully, False otherwise
"""
providers = self._get_enabled_providers(db)
if not providers:
# No audit providers available - this is acceptable
logger.debug(
f"No audit providers available for action {event.action} "
f"on {event.target_type}:{event.target_id}"
)
return False
any_success = False
for module, provider in providers:
try:
success = provider.log_action(db, event)
if success:
any_success = True
logger.debug(
f"Audit logged via {module.code}: {event.action} "
f"on {event.target_type}:{event.target_id}"
)
else:
logger.warning(
f"Audit provider {module.code} failed to log {event.action}"
)
except Exception as e:
logger.warning(
f"Failed to log audit via {module.code}: {e}"
)
# Continue with other providers - graceful degradation
return any_success
def log(
self,
db: Session,
admin_user_id: int,
action: str,
target_type: str,
target_id: str,
details: dict[str, Any] | None = None,
ip_address: str | None = None,
user_agent: str | None = None,
request_id: str | None = None,
) -> bool:
"""
Convenience method to log an audit event with individual parameters.
This is a shorthand for creating an AuditEvent and calling log_action.
Useful for simple logging calls without explicitly constructing the event.
Args:
db: Database session
admin_user_id: ID of the admin performing the action
action: Action performed (e.g., "create_vendor", "update_setting")
target_type: Type of target (e.g., "vendor", "user", "setting")
target_id: ID of the target entity (as string)
details: Additional context about the action
ip_address: IP address of the admin (optional)
user_agent: User agent string (optional)
request_id: Request ID for correlation (optional)
Returns:
True if at least one provider logged successfully, False otherwise
"""
event = AuditEvent(
admin_user_id=admin_user_id,
action=action,
target_type=target_type,
target_id=str(target_id),
details=details,
ip_address=ip_address,
user_agent=user_agent,
request_id=request_id,
)
return self.log_action(db, event)
def get_available_backends(self, db: Session) -> list[str]:
"""
Get list of available audit backends.
Args:
db: Database session
Returns:
List of backend names from enabled providers
"""
providers = self._get_enabled_providers(db)
return [provider.audit_backend for _, provider in providers]
# Singleton instance
audit_aggregator = AuditAggregatorService()
__all__ = ["AuditAggregatorService", "audit_aggregator"]

View File

@@ -19,9 +19,7 @@ from models.schema.auth import UserContext
from app.modules.customers.schemas import (
CustomerDetailResponse,
CustomerMessageResponse,
CustomerOrdersResponse,
CustomerResponse,
CustomerStatisticsResponse,
CustomerUpdate,
VendorCustomerListResponse,
)
@@ -79,7 +77,9 @@ def get_customer_details(
- Get customer by ID
- Verify customer belongs to vendor
- Include order statistics
Note: Order statistics are available via the orders module endpoint:
GET /api/vendor/customers/{customer_id}/order-stats
"""
# Service will raise CustomerNotFoundException if not found
customer = customer_service.get_customer(
@@ -88,13 +88,6 @@ def get_customer_details(
customer_id=customer_id,
)
# Get statistics
stats = customer_service.get_customer_statistics(
db=db,
vendor_id=current_user.token_vendor_id,
customer_id=customer_id,
)
return CustomerDetailResponse(
id=customer.id,
email=customer.email,
@@ -104,55 +97,10 @@ def get_customer_details(
customer_number=customer.customer_number,
is_active=customer.is_active,
marketing_consent=customer.marketing_consent,
total_orders=stats["total_orders"],
total_spent=stats["total_spent"],
average_order_value=stats["average_order_value"],
last_order_date=stats["last_order_date"],
created_at=customer.created_at,
)
@vendor_router.get("/{customer_id}/orders", response_model=CustomerOrdersResponse)
def get_customer_orders(
customer_id: int,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get order history for a specific customer.
- Get all orders for customer
- Filter by vendor_id
- Return order details
"""
# Service will raise CustomerNotFoundException if not found
orders, total = customer_service.get_customer_orders(
db=db,
vendor_id=current_user.token_vendor_id,
customer_id=customer_id,
skip=skip,
limit=limit,
)
return CustomerOrdersResponse(
orders=[
{
"id": o.id,
"order_number": o.order_number,
"status": o.status,
"total": o.total_cents / 100 if o.total_cents else 0,
"created_at": o.created_at,
}
for o in orders
],
total=total,
skip=skip,
limit=limit,
)
@vendor_router.put("/{customer_id}", response_model=CustomerMessageResponse)
def update_customer(
customer_id: int,
@@ -203,26 +151,3 @@ def toggle_customer_status(
status = "activated" if customer.is_active else "deactivated"
return CustomerMessageResponse(message=f"Customer {status} successfully")
@vendor_router.get("/{customer_id}/stats", response_model=CustomerStatisticsResponse)
def get_customer_statistics(
customer_id: int,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get customer statistics and metrics.
- Total orders
- Total spent
- Average order value
- Last order date
"""
# Service will raise CustomerNotFoundException if not found
stats = customer_service.get_customer_statistics(
db=db,
vendor_id=current_user.token_vendor_id,
customer_id=customer_id,
)
return CustomerStatisticsResponse(**stats)

View File

@@ -244,7 +244,12 @@ class VendorCustomerListResponse(BaseModel):
class CustomerDetailResponse(BaseModel):
"""Detailed customer response for vendor management."""
"""Detailed customer response for vendor management.
Note: Order-related statistics (total_orders, total_spent, last_order_date)
are available via the orders module endpoint:
GET /api/vendor/customers/{customer_id}/order-stats
"""
id: int | None = None
vendor_id: int | None = None
@@ -255,9 +260,6 @@ class CustomerDetailResponse(BaseModel):
customer_number: str | None = None
marketing_consent: bool | None = None
preferred_language: str | None = None
last_order_date: datetime | None = None
total_orders: int | None = None
total_spent: Decimal | None = None
is_active: bool | None = None
created_at: datetime | None = None
updated_at: datetime | None = None

View File

@@ -305,109 +305,11 @@ class CustomerService:
return customers, total
def get_customer_orders(
self,
db: Session,
vendor_id: int,
customer_id: int,
skip: int = 0,
limit: int = 50,
) -> tuple[list, int]:
"""
Get orders for a specific customer.
Args:
db: Database session
vendor_id: Vendor ID
customer_id: Customer ID
skip: Pagination offset
limit: Pagination limit
Returns:
Tuple of (orders, total_count)
Raises:
CustomerNotFoundException: If customer not found
"""
# Verify customer belongs to vendor
self.get_customer(db, vendor_id, customer_id)
try:
from app.modules.orders.models import Order
# Get customer orders
query = (
db.query(Order)
.filter(
Order.customer_id == customer_id,
Order.vendor_id == vendor_id,
)
.order_by(Order.created_at.desc())
)
total = query.count()
orders = query.offset(skip).limit(limit).all()
return orders, total
except ImportError:
# Orders module not available
logger.warning("Orders module not available for customer orders")
return [], 0
def get_customer_statistics(
self, db: Session, vendor_id: int, customer_id: int
) -> dict:
"""
Get detailed statistics for a customer.
Args:
db: Database session
vendor_id: Vendor ID
customer_id: Customer ID
Returns:
Dict with customer statistics
"""
customer = self.get_customer(db, vendor_id, customer_id)
# Get order statistics if orders module is available
try:
from sqlalchemy import func
from app.modules.orders.models import Order
order_stats = (
db.query(
func.count(Order.id).label("total_orders"),
func.sum(Order.total_cents).label("total_spent_cents"),
func.avg(Order.total_cents).label("avg_order_cents"),
func.max(Order.created_at).label("last_order_date"),
)
.filter(Order.customer_id == customer_id)
.first()
)
total_orders = order_stats.total_orders or 0
total_spent_cents = order_stats.total_spent_cents or 0
avg_order_cents = order_stats.avg_order_cents or 0
last_order_date = order_stats.last_order_date
except ImportError:
# Orders module not available
logger.warning("Orders module not available for customer statistics")
total_orders = 0
total_spent_cents = 0
avg_order_cents = 0
last_order_date = None
return {
"customer_id": customer_id,
"total_orders": total_orders,
"total_spent": total_spent_cents / 100, # Convert to euros
"average_order_value": avg_order_cents / 100 if avg_order_cents else 0.0,
"last_order_date": last_order_date,
"member_since": customer.created_at,
"is_active": customer.is_active,
}
# Note: Customer order methods have been moved to the orders module.
# Use orders.services.customer_order_service for:
# - get_customer_orders()
# Use orders.services.order_metrics.get_customer_order_metrics() for:
# - customer order statistics
def toggle_customer_status(
self, db: Session, vendor_id: int, customer_id: int

View File

@@ -59,7 +59,7 @@ async def admin_background_tasks_page(
"""
return templates.TemplateResponse(
"marketplace/admin/background-tasks.html",
get_admin_context(request, current_user, flower_url=settings.flower_url),
get_admin_context(request, db, current_user, flower_url=settings.flower_url),
)
@@ -127,7 +127,7 @@ async def admin_letzshop_order_detail_page(
"""
return templates.TemplateResponse(
"marketplace/admin/letzshop-order-detail.html",
get_admin_context(request, current_user, order_id=order_id),
get_admin_context(request, db, current_user, order_id=order_id),
)

View File

@@ -14,9 +14,11 @@ import logging
from sqlalchemy.orm import Session
from app.modules.contracts.widgets import (
BreakdownWidget,
DashboardWidget,
DashboardWidgetProviderProtocol,
ListWidget,
WidgetBreakdownItem,
WidgetContext,
WidgetListItem,
)
@@ -187,7 +189,7 @@ class MarketplaceWidgetProvider:
.count()
)
return [
widgets = [
DashboardWidget(
key="marketplace.recent_imports",
widget_type="list",
@@ -204,6 +206,82 @@ class MarketplaceWidgetProvider:
)
]
# Add marketplace breakdown widget
breakdown_widget = self._get_marketplace_breakdown_widget(db)
if breakdown_widget:
widgets.append(breakdown_widget)
return widgets
def _get_marketplace_breakdown_widget(
self,
db: Session,
) -> DashboardWidget | None:
"""
Get a breakdown widget showing statistics per marketplace.
Returns:
DashboardWidget with BreakdownWidget data, or None if no data
"""
from sqlalchemy import func
from app.modules.marketplace.models import MarketplaceProduct
try:
marketplace_stats = (
db.query(
MarketplaceProduct.marketplace,
func.count(MarketplaceProduct.id).label("total_products"),
func.count(func.distinct(MarketplaceProduct.vendor_name)).label(
"unique_vendors"
),
func.count(func.distinct(MarketplaceProduct.brand)).label(
"unique_brands"
),
)
.filter(MarketplaceProduct.marketplace.isnot(None))
.group_by(MarketplaceProduct.marketplace)
.all()
)
if not marketplace_stats:
return None
total_products = sum(stat.total_products for stat in marketplace_stats)
breakdown_items = [
WidgetBreakdownItem(
label=stat.marketplace or "Unknown",
value=stat.total_products,
secondary_value=stat.unique_vendors,
percentage=(
round(stat.total_products / total_products * 100, 1)
if total_products > 0
else 0
),
icon="globe",
)
for stat in marketplace_stats
]
return DashboardWidget(
key="marketplace.breakdown",
widget_type="breakdown",
title="Products by Marketplace",
category="marketplace",
data=BreakdownWidget(
items=breakdown_items,
total=total_products,
),
icon="chart-pie",
description="Product distribution across marketplaces",
order=30,
)
except Exception as e:
logger.warning(f"Failed to get marketplace breakdown widget: {e}")
return None
# Singleton instance
marketplace_widget_provider = MarketplaceWidgetProvider()

View File

@@ -83,7 +83,7 @@ async def admin_conversation_detail_page(
"""
return templates.TemplateResponse(
"messaging/admin/messages.html",
get_admin_context(request, current_user, conversation_id=conversation_id),
get_admin_context(request, db, current_user, conversation_id=conversation_id),
)

View File

@@ -17,6 +17,13 @@ def _get_admin_router():
return admin_router
def _get_audit_provider():
"""Lazy import of audit provider to avoid circular imports."""
from app.modules.monitoring.services.audit_provider import audit_provider
return audit_provider
# Monitoring module definition
monitoring_module = ModuleDefinition(
code="monitoring",
@@ -112,6 +119,10 @@ monitoring_module = ModuleDefinition(
is_core=False,
is_internal=True, # Internal module - admin-only, not customer-facing
# =========================================================================
# Audit Provider
# =========================================================================
audit_provider=_get_audit_provider,
# =========================================================================
# Self-Contained Module Configuration
# =========================================================================
is_self_contained=True,

View File

@@ -0,0 +1,78 @@
# app/modules/monitoring/services/audit_provider.py
"""
Audit provider implementation for the monitoring module.
Provides database-backed audit logging using the AdminAuditLog model.
This wraps the existing admin_audit_service functionality in the
AuditProviderProtocol interface.
"""
import logging
from typing import TYPE_CHECKING
from sqlalchemy.orm import Session
from app.modules.contracts.audit import AuditEvent, AuditProviderProtocol
from app.modules.tenancy.models import AdminAuditLog
if TYPE_CHECKING:
pass
logger = logging.getLogger(__name__)
class DatabaseAuditProvider:
"""
Database-backed audit provider.
Logs admin actions to the AdminAuditLog table.
This is the default audit backend for the platform.
"""
@property
def audit_backend(self) -> str:
return "database"
def log_action(self, db: Session, event: AuditEvent) -> bool:
"""
Log an audit event to the database.
Args:
db: Database session
event: The audit event to log
Returns:
True if logged successfully, False otherwise
"""
try:
audit_log = AdminAuditLog(
admin_user_id=event.admin_user_id,
action=event.action,
target_type=event.target_type,
target_id=str(event.target_id),
details=event.details or {},
ip_address=event.ip_address,
user_agent=event.user_agent,
request_id=event.request_id,
)
db.add(audit_log)
db.flush()
logger.debug(
f"Admin action logged: {event.action} on {event.target_type}:"
f"{event.target_id} by admin {event.admin_user_id}"
)
return True
except Exception as e:
logger.error(f"Failed to log admin action: {str(e)}")
# Don't raise exception - audit logging should not break operations
return False
# Singleton instance
audit_provider = DatabaseAuditProvider()
__all__ = ["DatabaseAuditProvider", "audit_provider"]

View File

@@ -284,6 +284,9 @@ def ship_order_item(
# ============================================================================
# Import sub-routers
from app.modules.orders.routes.api.vendor_customer_orders import (
vendor_customer_orders_router,
)
from app.modules.orders.routes.api.vendor_exceptions import vendor_exceptions_router
from app.modules.orders.routes.api.vendor_invoices import vendor_invoices_router
@@ -291,3 +294,4 @@ from app.modules.orders.routes.api.vendor_invoices import vendor_invoices_router
vendor_router.include_router(_orders_router, tags=["vendor-orders"])
vendor_router.include_router(vendor_exceptions_router, tags=["vendor-order-exceptions"])
vendor_router.include_router(vendor_invoices_router, tags=["vendor-invoices"])
vendor_router.include_router(vendor_customer_orders_router, tags=["vendor-customer-orders"])

View File

@@ -0,0 +1,163 @@
# app/modules/orders/routes/api/vendor_customer_orders.py
"""
Vendor customer order endpoints.
These endpoints provide customer-order data, owned by the orders module.
The orders module owns the relationship between customers and orders,
similar to how catalog owns the ProductMedia relationship.
Vendor Context: Uses token_vendor_id from JWT token.
"""
import logging
from datetime import datetime
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api, require_module_access
from app.core.database import get_db
from app.modules.enums import FrontendType
from app.modules.orders.services.customer_order_service import customer_order_service
from app.modules.orders.services.order_metrics import order_metrics_provider
from models.schema.auth import UserContext
logger = logging.getLogger(__name__)
# Router for customer-order endpoints
vendor_customer_orders_router = APIRouter(
prefix="/customers",
dependencies=[Depends(require_module_access("orders", FrontendType.VENDOR))],
)
# ============================================================================
# Response Models
# ============================================================================
class CustomerOrderItem(BaseModel):
"""Order summary for customer order list."""
id: int
order_number: str
status: str
total: float
created_at: datetime
class Config:
from_attributes = True
class CustomerOrdersResponse(BaseModel):
"""Response for customer orders list."""
orders: list[CustomerOrderItem]
total: int
skip: int
limit: int
class CustomerOrderStatistic(BaseModel):
"""Single statistic value."""
key: str
value: int | float | str
label: str
icon: str | None = None
unit: str | None = None
class CustomerOrderStatsResponse(BaseModel):
"""Response for customer order statistics."""
customer_id: int
statistics: list[CustomerOrderStatistic]
# ============================================================================
# Endpoints
# ============================================================================
@vendor_customer_orders_router.get(
"/{customer_id}/orders",
response_model=CustomerOrdersResponse,
)
def get_customer_orders(
customer_id: int,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get order history for a specific customer.
This endpoint is owned by the orders module because:
- Orders module owns the Order model and customer-order relationship
- Customers module is agnostic to what uses customers
Similar to how catalog owns ProductMedia (product-media relationship)
while CMS provides generic MediaFile storage.
"""
orders, total = customer_order_service.get_customer_orders(
db=db,
vendor_id=current_user.token_vendor_id,
customer_id=customer_id,
skip=skip,
limit=limit,
)
return CustomerOrdersResponse(
orders=[
CustomerOrderItem(
id=o.id,
order_number=o.order_number,
status=o.status,
total=o.total_cents / 100 if o.total_cents else 0,
created_at=o.created_at,
)
for o in orders
],
total=total,
skip=skip,
limit=limit,
)
@vendor_customer_orders_router.get(
"/{customer_id}/order-stats",
response_model=CustomerOrderStatsResponse,
)
def get_customer_order_stats(
customer_id: int,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get order statistics for a specific customer.
Uses the MetricsProvider pattern to provide customer-level order metrics.
Returns statistics like total orders, total spent, average order value, etc.
"""
metrics = order_metrics_provider.get_customer_order_metrics(
db=db,
vendor_id=current_user.token_vendor_id,
customer_id=customer_id,
)
return CustomerOrderStatsResponse(
customer_id=customer_id,
statistics=[
CustomerOrderStatistic(
key=m.key,
value=m.value,
label=m.label,
icon=m.icon,
unit=m.unit,
)
for m in metrics
],
)

View File

@@ -25,6 +25,10 @@ from app.modules.orders.services.invoice_pdf_service import (
invoice_pdf_service,
InvoicePDFService,
)
from app.modules.orders.services.customer_order_service import (
customer_order_service,
CustomerOrderService,
)
__all__ = [
"order_service",
@@ -37,4 +41,6 @@ __all__ = [
"InvoiceService",
"invoice_pdf_service",
"InvoicePDFService",
"customer_order_service",
"CustomerOrderService",
]

View File

@@ -0,0 +1,122 @@
# app/modules/orders/services/customer_order_service.py
"""
Customer-order service for managing customer-order relationships.
This service handles the orders-specific logic for customer order operations.
It follows the principle that:
- Customers module provides generic customer storage (agnostic to consumers)
- Orders module defines how it relates to customers (owns the relationship)
Similar to how:
- CMS provides generic media storage
- Catalog defines ProductMedia association
"""
import logging
from sqlalchemy.orm import Session
from app.modules.orders.models import Order
logger = logging.getLogger(__name__)
class CustomerOrderService:
"""Service for customer-order operations owned by orders module."""
def get_customer_orders(
self,
db: Session,
vendor_id: int,
customer_id: int,
skip: int = 0,
limit: int = 50,
) -> tuple[list[Order], int]:
"""
Get orders for a specific customer.
Args:
db: Database session
vendor_id: Vendor ID (for ownership verification)
customer_id: Customer ID
skip: Pagination offset
limit: Pagination limit
Returns:
Tuple of (orders, total_count)
"""
query = (
db.query(Order)
.filter(
Order.customer_id == customer_id,
Order.vendor_id == vendor_id,
)
.order_by(Order.created_at.desc())
)
total = query.count()
orders = query.offset(skip).limit(limit).all()
return orders, total
def get_recent_orders(
self,
db: Session,
vendor_id: int,
customer_id: int,
limit: int = 5,
) -> list[Order]:
"""
Get recent orders for a customer.
Args:
db: Database session
vendor_id: Vendor ID
customer_id: Customer ID
limit: Maximum orders to return
Returns:
List of recent orders
"""
return (
db.query(Order)
.filter(
Order.customer_id == customer_id,
Order.vendor_id == vendor_id,
)
.order_by(Order.created_at.desc())
.limit(limit)
.all()
)
def get_order_count(
self,
db: Session,
vendor_id: int,
customer_id: int,
) -> int:
"""
Get total order count for a customer.
Args:
db: Database session
vendor_id: Vendor ID
customer_id: Customer ID
Returns:
Total order count
"""
return (
db.query(Order)
.filter(
Order.customer_id == customer_id,
Order.vendor_id == vendor_id,
)
.count()
)
# Singleton instance
customer_order_service = CustomerOrderService()
__all__ = ["CustomerOrderService", "customer_order_service"]

View File

@@ -302,6 +302,111 @@ class OrderMetricsProvider:
return []
def get_customer_order_metrics(
self,
db: Session,
vendor_id: int,
customer_id: int,
context: MetricsContext | None = None,
) -> list[MetricValue]:
"""
Get order metrics for a specific customer.
This is an entity-level method (not dashboard-level) that provides
order statistics for a specific customer. Used by customer detail pages.
Args:
db: Database session
vendor_id: Vendor ID (for ownership verification)
customer_id: Customer ID
context: Optional filtering context
Returns:
List of MetricValue objects for this customer's order activity
"""
from app.modules.orders.models import Order
try:
# Base query for customer orders
base_query = db.query(Order).filter(
Order.customer_id == customer_id,
Order.vendor_id == vendor_id,
)
# Total orders
total_orders = base_query.count()
# Revenue stats
revenue_query = db.query(
func.sum(Order.total_amount_cents).label("total_spent_cents"),
func.avg(Order.total_amount_cents).label("avg_order_cents"),
func.max(Order.created_at).label("last_order_date"),
func.min(Order.created_at).label("first_order_date"),
).filter(
Order.customer_id == customer_id,
Order.vendor_id == vendor_id,
)
stats = revenue_query.first()
total_spent_cents = stats.total_spent_cents or 0
avg_order_cents = stats.avg_order_cents or 0
last_order_date = stats.last_order_date
first_order_date = stats.first_order_date
# Convert cents to currency
total_spent = total_spent_cents / 100
avg_order_value = avg_order_cents / 100 if avg_order_cents else 0.0
return [
MetricValue(
key="customer.total_orders",
value=total_orders,
label="Total Orders",
category="customer_orders",
icon="shopping-bag",
description="Total orders placed by this customer",
),
MetricValue(
key="customer.total_spent",
value=round(total_spent, 2),
label="Total Spent",
category="customer_orders",
icon="currency-euro",
unit="EUR",
description="Total amount spent by this customer",
),
MetricValue(
key="customer.avg_order_value",
value=round(avg_order_value, 2),
label="Avg Order Value",
category="customer_orders",
icon="calculator",
unit="EUR",
description="Average order value for this customer",
),
MetricValue(
key="customer.last_order_date",
value=last_order_date.isoformat() if last_order_date else "",
label="Last Order",
category="customer_orders",
icon="calendar",
description="Date of most recent order",
),
MetricValue(
key="customer.first_order_date",
value=first_order_date.isoformat() if first_order_date else "",
label="First Order",
category="customer_orders",
icon="calendar-plus",
description="Date of first order",
),
]
except Exception as e:
logger.warning(f"Failed to get customer order metrics: {e}")
return []
# Singleton instance
order_metrics_provider = OrderMetricsProvider()

View File

@@ -9,11 +9,12 @@ by the global exception handler.
import logging
from fastapi import APIRouter, Body, Depends, Path, Query
from fastapi import APIRouter, Body, Depends, Path, Query, Request
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.modules.core.services.stats_aggregator import stats_aggregator
from app.modules.tenancy.services.admin_service import admin_service
from models.schema.auth import UserContext
from models.schema.auth import (
@@ -104,25 +105,60 @@ def create_user(
)
def _get_platform_id(request: Request, current_admin: UserContext) -> int:
"""Get platform_id from available sources."""
# From JWT token
if current_admin.token_platform_id:
return current_admin.token_platform_id
# From request state
platform = getattr(request.state, "platform", None)
if platform:
return platform.id
# First accessible platform
if current_admin.accessible_platform_ids:
return current_admin.accessible_platform_ids[0]
# Fallback
return 1
@admin_platform_users_router.get("/stats")
def get_user_statistics(
request: Request,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get user statistics for admin dashboard (Admin only)."""
try:
from app.modules.analytics.services.stats_service import stats_service
"""Get user statistics for admin dashboard (Admin only).
return stats_service.get_user_statistics(db)
except ImportError:
# Analytics module not available - return empty stats
logger.warning("Analytics module not available for user statistics")
return {
"total_users": 0,
"active_users": 0,
"inactive_users": 0,
"admin_users": 0,
}
Uses the stats_aggregator to get user metrics from the tenancy module's
MetricsProvider, ensuring no cross-module import violations.
"""
platform_id = _get_platform_id(request, current_admin)
# Get metrics from stats_aggregator (tenancy module provides user stats)
metrics = stats_aggregator.get_admin_dashboard_stats(db=db, platform_id=platform_id)
# Extract user stats from tenancy metrics
tenancy_metrics = metrics.get("tenancy", [])
# Build response from metric values
stats = {
"total_users": 0,
"active_users": 0,
"inactive_users": 0,
"admin_users": 0,
}
for metric in tenancy_metrics:
if metric.key == "tenancy.total_users":
stats["total_users"] = int(metric.value)
elif metric.key == "tenancy.active_users":
stats["active_users"] = int(metric.value)
elif metric.key == "tenancy.inactive_users":
stats["inactive_users"] = int(metric.value)
elif metric.key == "tenancy.admin_users":
stats["admin_users"] = int(metric.value)
return stats
@admin_platform_users_router.get("/search", response_model=UserSearchResponse)

View File

@@ -74,7 +74,7 @@ async def admin_company_detail_page(
"""
return templates.TemplateResponse(
"tenancy/admin/company-detail.html",
get_admin_context(request, current_user, company_id=company_id),
get_admin_context(request, db, current_user, company_id=company_id),
)
@@ -94,7 +94,7 @@ async def admin_company_edit_page(
"""
return templates.TemplateResponse(
"tenancy/admin/company-edit.html",
get_admin_context(request, current_user, company_id=company_id),
get_admin_context(request, db, current_user, company_id=company_id),
)
@@ -149,7 +149,7 @@ async def admin_vendor_detail_page(
"""
return templates.TemplateResponse(
"tenancy/admin/vendor-detail.html",
get_admin_context(request, current_user, vendor_code=vendor_code),
get_admin_context(request, db, current_user, vendor_code=vendor_code),
)
@@ -167,7 +167,7 @@ async def admin_vendor_edit_page(
"""
return templates.TemplateResponse(
"tenancy/admin/vendor-edit.html",
get_admin_context(request, current_user, vendor_code=vendor_code),
get_admin_context(request, db, current_user, vendor_code=vendor_code),
)
@@ -193,7 +193,7 @@ async def admin_vendor_domains_page(
"""
return templates.TemplateResponse(
"tenancy/admin/vendor-domains.html",
get_admin_context(request, current_user, vendor_code=vendor_code),
get_admin_context(request, db, current_user, vendor_code=vendor_code),
)
@@ -239,7 +239,7 @@ async def admin_vendor_theme_page(
"""
return templates.TemplateResponse(
"tenancy/admin/vendor-theme.html",
get_admin_context(request, current_user, vendor_code=vendor_code),
get_admin_context(request, db, current_user, vendor_code=vendor_code),
)
@@ -304,7 +304,7 @@ async def admin_user_detail_page(
"""
return templates.TemplateResponse(
"tenancy/admin/admin-user-detail.html",
get_admin_context(request, current_user, user_id=user_id),
get_admin_context(request, db, current_user, user_id=user_id),
)
@@ -325,7 +325,7 @@ async def admin_user_edit_page(
"""
return templates.TemplateResponse(
"tenancy/admin/admin-user-edit.html",
get_admin_context(request, current_user, user_id=user_id),
get_admin_context(request, db, current_user, user_id=user_id),
)
@@ -410,7 +410,7 @@ async def admin_platform_detail(
"""
return templates.TemplateResponse(
"tenancy/admin/platform-detail.html",
get_admin_context(request, current_user, platform_code=platform_code),
get_admin_context(request, db, current_user, platform_code=platform_code),
)
@@ -431,7 +431,7 @@ async def admin_platform_edit(
"""
return templates.TemplateResponse(
"tenancy/admin/platform-edit.html",
get_admin_context(request, current_user, platform_code=platform_code),
get_admin_context(request, db, current_user, platform_code=platform_code),
)
@@ -458,7 +458,7 @@ async def admin_platform_menu_config(
return templates.TemplateResponse(
"tenancy/admin/platform-menu-config.html",
get_admin_context(request, current_user, platform_code=platform_code),
get_admin_context(request, db, current_user, platform_code=platform_code),
)
@@ -485,7 +485,7 @@ async def admin_platform_modules(
return templates.TemplateResponse(
"tenancy/admin/platform-modules.html",
get_admin_context(request, current_user, platform_code=platform_code),
get_admin_context(request, db, current_user, platform_code=platform_code),
)