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

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