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:
@@ -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"]
|
||||
Reference in New Issue
Block a user