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:
298
docs/architecture/media-architecture.md
Normal file
298
docs/architecture/media-architecture.md
Normal file
@@ -0,0 +1,298 @@
|
||||
# Media Management Architecture
|
||||
|
||||
This document describes the consumer-agnostic media architecture used in the platform.
|
||||
|
||||
## Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ CONSUMER MODULES │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ Catalog │ │ Art Gallery │ │ Future │ │
|
||||
│ │ │ │ (future) │ │ Module │ │
|
||||
│ │ ProductMedia│ │ GalleryMedia│ │ XxxMedia │ │
|
||||
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
|
||||
│ │ │ │ │
|
||||
│ └──────────────────┼──────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ CMS Module │ │
|
||||
│ │ │ │
|
||||
│ │ MediaFile (generic, consumer-agnostic storage) │ │
|
||||
│ │ MediaService (upload, download, metadata management) │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Design Principles
|
||||
|
||||
### 1. Consumer-Agnostic Storage
|
||||
|
||||
The CMS module provides **generic media storage** without knowing what entities will use the media files. This means:
|
||||
|
||||
- `MediaFile` stores file metadata (path, size, type, dimensions)
|
||||
- `MediaService` handles upload, download, and file operations
|
||||
- CMS has **no knowledge** of products, galleries, or any specific consumers
|
||||
|
||||
### 2. Consumer-Owned Associations
|
||||
|
||||
Each module that needs media defines its **own association table**:
|
||||
|
||||
- **Catalog**: `ProductMedia` links products to media files
|
||||
- **Future Art Gallery**: Would define `GalleryMedia`
|
||||
- **Future Blog**: Would define `PostMedia`
|
||||
|
||||
This follows the principle: **The consumer knows what it needs, the provider doesn't need to know who uses it.**
|
||||
|
||||
### 3. Correct Dependency Direction
|
||||
|
||||
```
|
||||
WRONG (Hidden Dependency):
|
||||
CMS → Catalog (CMS knows about Product)
|
||||
|
||||
CORRECT:
|
||||
Catalog → CMS (Catalog uses MediaFile)
|
||||
```
|
||||
|
||||
Optional modules (catalog) depend on core modules (cms), never the reverse.
|
||||
|
||||
## Key Components
|
||||
|
||||
### MediaFile Model (CMS)
|
||||
|
||||
```python
|
||||
# app/modules/cms/models/media.py
|
||||
|
||||
class MediaFile(Base, TimestampMixin):
|
||||
"""Generic vendor media file - consumer-agnostic."""
|
||||
|
||||
__tablename__ = "media_files"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
|
||||
|
||||
# File identification
|
||||
filename = Column(String(255), nullable=False) # UUID-based
|
||||
original_filename = Column(String(255))
|
||||
file_path = Column(String(500), nullable=False)
|
||||
|
||||
# File properties
|
||||
media_type = Column(String(20), nullable=False) # image, video, document
|
||||
mime_type = Column(String(100))
|
||||
file_size = Column(Integer)
|
||||
|
||||
# Image/video dimensions
|
||||
width = Column(Integer)
|
||||
height = Column(Integer)
|
||||
|
||||
# Metadata
|
||||
alt_text = Column(String(500))
|
||||
description = Column(Text)
|
||||
folder = Column(String(100), default="general")
|
||||
|
||||
# Usage tracking (updated by consumers)
|
||||
usage_count = Column(Integer, default=0)
|
||||
|
||||
# Note: Consumer-specific associations (ProductMedia, etc.) are defined
|
||||
# in their respective modules. CMS doesn't know about specific consumers.
|
||||
```
|
||||
|
||||
### MediaService (CMS)
|
||||
|
||||
The `MediaService` provides generic operations:
|
||||
|
||||
```python
|
||||
# app/modules/cms/services/media_service.py
|
||||
|
||||
class MediaService:
|
||||
"""Generic media operations - consumer-agnostic."""
|
||||
|
||||
async def upload_file(self, db, vendor_id, file_content, filename, folder="general"):
|
||||
"""Upload a file to vendor's media library."""
|
||||
...
|
||||
|
||||
def get_media(self, db, vendor_id, media_id):
|
||||
"""Get a media file by ID."""
|
||||
...
|
||||
|
||||
def get_media_library(self, db, vendor_id, skip=0, limit=100, **filters):
|
||||
"""List vendor's media files with filtering."""
|
||||
...
|
||||
|
||||
def update_media_metadata(self, db, vendor_id, media_id, **metadata):
|
||||
"""Update file metadata (alt_text, description, etc.)."""
|
||||
...
|
||||
|
||||
def delete_media(self, db, vendor_id, media_id):
|
||||
"""Delete a media file."""
|
||||
...
|
||||
```
|
||||
|
||||
### ProductMedia Model (Catalog)
|
||||
|
||||
```python
|
||||
# app/modules/catalog/models/product_media.py
|
||||
|
||||
class ProductMedia(Base, TimestampMixin):
|
||||
"""Product-specific media association - owned by catalog."""
|
||||
|
||||
__tablename__ = "product_media"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id", ondelete="CASCADE"))
|
||||
media_id = Column(Integer, ForeignKey("media_files.id", ondelete="CASCADE"))
|
||||
|
||||
# Usage type: main_image, gallery, variant, thumbnail, swatch
|
||||
usage_type = Column(String(50), nullable=False, default="gallery")
|
||||
display_order = Column(Integer, default=0)
|
||||
variant_id = Column(Integer) # For variant-specific images
|
||||
|
||||
# Relationships
|
||||
product = relationship("Product", back_populates="media_associations")
|
||||
media = relationship("MediaFile", lazy="joined")
|
||||
```
|
||||
|
||||
### ProductMediaService (Catalog)
|
||||
|
||||
```python
|
||||
# app/modules/catalog/services/product_media_service.py
|
||||
|
||||
class ProductMediaService:
|
||||
"""Product-media association operations - catalog-specific."""
|
||||
|
||||
def attach_media_to_product(self, db, vendor_id, product_id, media_id,
|
||||
usage_type="gallery", display_order=0):
|
||||
"""Attach a media file to a product."""
|
||||
# Verify ownership, create ProductMedia association
|
||||
...
|
||||
|
||||
def detach_media_from_product(self, db, vendor_id, product_id, media_id,
|
||||
usage_type=None):
|
||||
"""Detach media from a product."""
|
||||
...
|
||||
|
||||
def get_product_media(self, db, product_id, usage_type=None):
|
||||
"""Get media associations for a product."""
|
||||
...
|
||||
|
||||
def set_main_image(self, db, vendor_id, product_id, media_id):
|
||||
"""Set the main image for a product."""
|
||||
...
|
||||
```
|
||||
|
||||
## Adding Media Support to a New Module
|
||||
|
||||
When creating a new module that needs media (e.g., an art gallery module):
|
||||
|
||||
### Step 1: Create Your Association Model
|
||||
|
||||
```python
|
||||
# app/modules/art_gallery/models/gallery_media.py
|
||||
|
||||
from sqlalchemy import Column, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.core.database import Base
|
||||
|
||||
class GalleryMedia(Base):
|
||||
"""Gallery-specific media association."""
|
||||
|
||||
__tablename__ = "gallery_media"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
artwork_id = Column(Integer, ForeignKey("artworks.id", ondelete="CASCADE"))
|
||||
media_id = Column(Integer, ForeignKey("media_files.id", ondelete="CASCADE"))
|
||||
|
||||
# Gallery-specific fields
|
||||
is_primary = Column(Boolean, default=False)
|
||||
caption = Column(String(500))
|
||||
|
||||
# Relationships
|
||||
artwork = relationship("Artwork", back_populates="media_associations")
|
||||
media = relationship("MediaFile", lazy="joined")
|
||||
```
|
||||
|
||||
### Step 2: Create Your Service
|
||||
|
||||
```python
|
||||
# app/modules/art_gallery/services/gallery_media_service.py
|
||||
|
||||
from app.modules.cms.models import MediaFile
|
||||
from app.modules.art_gallery.models import GalleryMedia, Artwork
|
||||
|
||||
class GalleryMediaService:
|
||||
def attach_media_to_artwork(self, db, artist_id, artwork_id, media_id, **kwargs):
|
||||
# Verify artwork belongs to artist
|
||||
# Verify media belongs to artist (vendor_id)
|
||||
# Create GalleryMedia association
|
||||
...
|
||||
```
|
||||
|
||||
### Step 3: Use CMS MediaService for File Operations
|
||||
|
||||
```python
|
||||
from app.modules.cms.services.media_service import media_service
|
||||
|
||||
# Upload a new file
|
||||
media_file = await media_service.upload_file(
|
||||
db=db,
|
||||
vendor_id=artist_id,
|
||||
file_content=file_bytes,
|
||||
filename="artwork.jpg",
|
||||
folder="artworks",
|
||||
)
|
||||
|
||||
# Then attach it to your entity
|
||||
gallery_media_service.attach_media_to_artwork(
|
||||
db=db,
|
||||
artist_id=artist_id,
|
||||
artwork_id=artwork.id,
|
||||
media_id=media_file.id,
|
||||
is_primary=True,
|
||||
)
|
||||
```
|
||||
|
||||
## Benefits of This Architecture
|
||||
|
||||
1. **Module Independence**: Catalog can be disabled without affecting CMS
|
||||
2. **Extensibility**: New modules easily add media support
|
||||
3. **No Hidden Dependencies**: Dependencies flow in one direction
|
||||
4. **Clean Separation**: CMS handles storage, consumers handle associations
|
||||
5. **Testability**: Can test CMS without any consumer modules
|
||||
6. **Single Responsibility**: Each module owns its domain
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
### Don't: Add Consumer References to MediaFile
|
||||
|
||||
```python
|
||||
# BAD - Creates hidden dependency
|
||||
class MediaFile(Base):
|
||||
# CMS now depends on catalog!
|
||||
product_associations = relationship("ProductMedia", ...)
|
||||
```
|
||||
|
||||
### Don't: Put Consumer Logic in MediaService
|
||||
|
||||
```python
|
||||
# BAD - CMS shouldn't know about products
|
||||
class MediaService:
|
||||
def attach_to_product(self, db, product_id, media_id):
|
||||
from app.modules.catalog.models import ProductMedia # Wrong!
|
||||
```
|
||||
|
||||
### Don't: Query Consumer Models from CMS
|
||||
|
||||
```python
|
||||
# BAD - Hidden dependency
|
||||
def get_media_usage(self, db, media_id):
|
||||
from app.modules.catalog.models import ProductMedia # Wrong!
|
||||
return db.query(ProductMedia).filter(...).all()
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Module System Architecture](module-system.md) - Module structure and dependencies
|
||||
- [Cross-Module Import Rules](cross-module-import-rules.md) - Import restrictions
|
||||
- [Audit Provider Pattern](audit-provider-pattern.md) - Similar provider pattern for audit logging
|
||||
Reference in New Issue
Block a user