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:
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
# =========================================================================
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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__ = (
|
||||
|
||||
@@ -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(
|
||||
|
||||
280
app/modules/catalog/services/product_media_service.py
Normal file
280
app/modules/catalog/services/product_media_service.py
Normal 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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
168
app/modules/contracts/audit.py
Normal file
168
app/modules/contracts/audit.py
Normal 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",
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
216
app/modules/core/services/audit_aggregator.py
Normal file
216
app/modules/core/services/audit_aggregator.py
Normal 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"]
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
78
app/modules/monitoring/services/audit_provider.py
Normal file
78
app/modules/monitoring/services/audit_provider.py
Normal 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"]
|
||||
@@ -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"])
|
||||
|
||||
163
app/modules/orders/routes/api/vendor_customer_orders.py
Normal file
163
app/modules/orders/routes/api/vendor_customer_orders.py
Normal 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
|
||||
],
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
122
app/modules/orders/services/customer_order_service.py
Normal file
122
app/modules/orders/services/customer_order_service.py
Normal 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"]
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user