feat: implement product search, media library, and vendor customers
- Add full-text product search in ProductService.search_products() searching titles, descriptions, SKUs, brands, and GTINs - Implement complete vendor media library with file uploads, thumbnails, folders, and product associations - Implement vendor customers API with listing, details, orders, statistics, and status management - Add shop search results UI with pagination and add-to-cart - Add vendor media library UI with drag-drop upload and grid view - Add database migration for media_files and product_media tables - Update TODO file with current launch status (~95% complete) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -51,6 +51,7 @@ from .marketplace_product import (
|
||||
MarketplaceProduct,
|
||||
ProductType,
|
||||
)
|
||||
from .media import MediaFile, ProductMedia
|
||||
from .marketplace_product_translation import MarketplaceProductTranslation
|
||||
from .onboarding import OnboardingStatus, OnboardingStep, VendorOnboarding
|
||||
from .order import Order, OrderItem
|
||||
@@ -138,6 +139,9 @@ __all__ = [
|
||||
"Inventory",
|
||||
"InventoryTransaction",
|
||||
"TransactionType",
|
||||
# Media
|
||||
"MediaFile",
|
||||
"ProductMedia",
|
||||
# Invoicing
|
||||
"Invoice",
|
||||
"InvoiceStatus",
|
||||
|
||||
@@ -1 +1,173 @@
|
||||
# MediaFile, ProductMedia models
|
||||
# models/database/media.py
|
||||
"""
|
||||
Media file models for vendor media library.
|
||||
|
||||
This module provides:
|
||||
- MediaFile: Vendor-uploaded media files (images, documents, videos)
|
||||
- ProductMedia: Many-to-many relationship between products and media
|
||||
|
||||
Files are stored in vendor-specific directories:
|
||||
uploads/vendors/{vendor_id}/{folder}/{filename}
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.dialects.sqlite import JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class MediaFile(Base, TimestampMixin):
|
||||
"""Vendor media file record.
|
||||
|
||||
Stores metadata about uploaded files. Actual files are stored
|
||||
in the filesystem at uploads/vendors/{vendor_id}/{folder}/
|
||||
"""
|
||||
|
||||
__tablename__ = "media_files"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
|
||||
|
||||
# File identification
|
||||
filename = Column(String(255), nullable=False) # Stored filename (UUID-based)
|
||||
original_filename = Column(String(255)) # Original uploaded filename
|
||||
file_path = Column(String(500), nullable=False) # Relative path from uploads/
|
||||
|
||||
# File properties
|
||||
media_type = Column(String(20), nullable=False) # image, video, document
|
||||
mime_type = Column(String(100))
|
||||
file_size = Column(Integer) # bytes
|
||||
|
||||
# Image/video dimensions
|
||||
width = Column(Integer)
|
||||
height = Column(Integer)
|
||||
|
||||
# Thumbnail (for images/videos)
|
||||
thumbnail_path = Column(String(500))
|
||||
|
||||
# Metadata
|
||||
alt_text = Column(String(500))
|
||||
description = Column(Text)
|
||||
folder = Column(String(100), default="general") # products, general, etc.
|
||||
tags = Column(JSON) # List of tags for categorization
|
||||
extra_metadata = Column(JSON) # Additional metadata (EXIF, etc.)
|
||||
|
||||
# Status
|
||||
is_optimized = Column(Boolean, default=False)
|
||||
optimized_size = Column(Integer) # Size after optimization
|
||||
|
||||
# Usage tracking
|
||||
usage_count = Column(Integer, default=0) # How many times used
|
||||
|
||||
# Relationships
|
||||
vendor = relationship("Vendor", back_populates="media_files")
|
||||
product_associations = relationship(
|
||||
"ProductMedia",
|
||||
back_populates="media",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_media_vendor_id", "vendor_id"),
|
||||
Index("idx_media_vendor_folder", "vendor_id", "folder"),
|
||||
Index("idx_media_vendor_type", "vendor_id", "media_type"),
|
||||
Index("idx_media_filename", "filename"),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<MediaFile(id={self.id}, vendor_id={self.vendor_id}, "
|
||||
f"filename='{self.filename}', type='{self.media_type}')>"
|
||||
)
|
||||
|
||||
@property
|
||||
def file_url(self) -> str:
|
||||
"""Get the public URL for this file."""
|
||||
return f"/uploads/{self.file_path}"
|
||||
|
||||
@property
|
||||
def thumbnail_url(self) -> str | None:
|
||||
"""Get the thumbnail URL if available."""
|
||||
if self.thumbnail_path:
|
||||
return f"/uploads/{self.thumbnail_path}"
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_image(self) -> bool:
|
||||
"""Check if this is an image file."""
|
||||
return self.media_type == "image"
|
||||
|
||||
@property
|
||||
def is_video(self) -> bool:
|
||||
"""Check if this is a video file."""
|
||||
return self.media_type == "video"
|
||||
|
||||
@property
|
||||
def is_document(self) -> bool:
|
||||
"""Check if this is a document file."""
|
||||
return self.media_type == "document"
|
||||
|
||||
|
||||
class ProductMedia(Base, TimestampMixin):
|
||||
"""Association between products and media files.
|
||||
|
||||
Tracks which media files are used by which products,
|
||||
including the usage type (main image, gallery, variant, etc.)
|
||||
"""
|
||||
|
||||
__tablename__ = "product_media"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(
|
||||
Integer,
|
||||
ForeignKey("products.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
media_id = Column(
|
||||
Integer,
|
||||
ForeignKey("media_files.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Usage type
|
||||
usage_type = Column(String(50), nullable=False, default="gallery")
|
||||
# Types: main_image, gallery, variant, thumbnail, swatch
|
||||
|
||||
# Display order for galleries
|
||||
display_order = Column(Integer, default=0)
|
||||
|
||||
# Variant-specific (if usage_type is variant)
|
||||
variant_id = Column(Integer) # Reference to variant if applicable
|
||||
|
||||
# Relationships
|
||||
product = relationship("Product")
|
||||
media = relationship("MediaFile", back_populates="product_associations")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"product_id", "media_id", "usage_type",
|
||||
name="uq_product_media_usage"
|
||||
),
|
||||
Index("idx_product_media_product", "product_id"),
|
||||
Index("idx_product_media_media", "media_id"),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<ProductMedia(product_id={self.product_id}, "
|
||||
f"media_id={self.media_id}, usage='{self.usage_type}')>"
|
||||
)
|
||||
|
||||
@@ -238,6 +238,13 @@ class Vendor(Base, TimestampMixin):
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# Media library (one-to-many)
|
||||
media_files = relationship(
|
||||
"MediaFile",
|
||||
back_populates="vendor",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
"""String representation of the Vendor object."""
|
||||
return f"<Vendor(id={self.id}, vendor_code='{self.vendor_code}', name='{self.name}', subdomain='{self.subdomain}')>"
|
||||
|
||||
@@ -46,7 +46,7 @@ class MediaItemResponse(BaseModel):
|
||||
alt_text: str | None = None
|
||||
description: str | None = None
|
||||
folder: str | None = None
|
||||
metadata: dict[str, Any] | None = None
|
||||
extra_metadata: dict[str, Any] | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
@@ -127,7 +127,7 @@ class MediaDetailResponse(BaseModel):
|
||||
alt_text: str | None = None
|
||||
description: str | None = None
|
||||
folder: str | None = None
|
||||
metadata: dict[str, Any] | None = None
|
||||
extra_metadata: dict[str, Any] | None = None
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
message: str | None = None
|
||||
@@ -147,7 +147,7 @@ class MediaMetadataUpdate(BaseModel):
|
||||
alt_text: str | None = Field(None, max_length=500)
|
||||
description: str | None = None
|
||||
folder: str | None = Field(None, max_length=100)
|
||||
metadata: dict[str, Any] | None = None
|
||||
metadata: dict[str, Any] | None = None # Named 'metadata' in API, stored as 'extra_metadata'
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user