- 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>
334 lines
9.5 KiB
Python
334 lines
9.5 KiB
Python
# app/api/v1/vendor/media.py
|
|
"""
|
|
Vendor media and file management endpoints.
|
|
|
|
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
|
|
The get_current_vendor_api dependency guarantees token_vendor_id is present.
|
|
"""
|
|
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.api.deps import get_current_vendor_api
|
|
from app.core.database import get_db
|
|
from app.services.media_service import MediaNotFoundException, media_service
|
|
from models.database.user import User
|
|
from models.schema.media import (
|
|
MediaDetailResponse,
|
|
MediaItemResponse,
|
|
MediaListResponse,
|
|
MediaMetadataUpdate,
|
|
MediaUploadResponse,
|
|
MediaUsageResponse,
|
|
MultipleUploadResponse,
|
|
OptimizationResultResponse,
|
|
UploadedFileInfo,
|
|
FailedFileInfo,
|
|
)
|
|
|
|
router = APIRouter(prefix="/media")
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@router.get("", response_model=MediaListResponse)
|
|
def get_media_library(
|
|
skip: int = Query(0, ge=0),
|
|
limit: int = Query(100, ge=1, le=1000),
|
|
media_type: str | None = Query(None, description="image, video, document"),
|
|
folder: str | None = Query(None, description="Filter by folder"),
|
|
search: str | None = Query(None),
|
|
current_user: User = Depends(get_current_vendor_api),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Get vendor media library.
|
|
|
|
- Get all media files for vendor
|
|
- Filter by type (image, video, document)
|
|
- Filter by folder
|
|
- Search by filename
|
|
- Support pagination
|
|
"""
|
|
media_files, total = media_service.get_media_library(
|
|
db=db,
|
|
vendor_id=current_user.token_vendor_id,
|
|
skip=skip,
|
|
limit=limit,
|
|
media_type=media_type,
|
|
folder=folder,
|
|
search=search,
|
|
)
|
|
|
|
return MediaListResponse(
|
|
media=[MediaItemResponse.model_validate(m) for m in media_files],
|
|
total=total,
|
|
skip=skip,
|
|
limit=limit,
|
|
)
|
|
|
|
|
|
@router.post("/upload", response_model=MediaUploadResponse)
|
|
async def upload_media(
|
|
file: UploadFile = File(...),
|
|
folder: str | None = Query("general", description="products, general, etc."),
|
|
current_user: User = Depends(get_current_vendor_api),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Upload media file.
|
|
|
|
- Accept file upload
|
|
- Validate file type and size
|
|
- Store file in vendor-specific directory
|
|
- Generate thumbnails for images
|
|
- Save metadata to database
|
|
- Return file URL
|
|
"""
|
|
try:
|
|
# Read file content
|
|
file_content = await file.read()
|
|
|
|
# Upload using service
|
|
media_file = await media_service.upload_file(
|
|
db=db,
|
|
vendor_id=current_user.token_vendor_id,
|
|
file_content=file_content,
|
|
filename=file.filename or "unnamed",
|
|
folder=folder or "general",
|
|
)
|
|
|
|
db.commit()
|
|
|
|
return MediaUploadResponse(
|
|
id=media_file.id,
|
|
file_url=media_file.file_url,
|
|
thumbnail_url=media_file.thumbnail_url,
|
|
filename=media_file.original_filename,
|
|
file_size=media_file.file_size,
|
|
media_type=media_file.media_type,
|
|
message="File uploaded successfully",
|
|
)
|
|
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.error(f"Failed to upload media: {e}")
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
@router.post("/upload/multiple", response_model=MultipleUploadResponse)
|
|
async def upload_multiple_media(
|
|
files: list[UploadFile] = File(...),
|
|
folder: str | None = Query("general"),
|
|
current_user: User = Depends(get_current_vendor_api),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Upload multiple media files at once.
|
|
|
|
- Accept multiple files
|
|
- Process each file
|
|
- Return list of uploaded file URLs
|
|
- Handle errors gracefully
|
|
"""
|
|
uploaded = []
|
|
failed = []
|
|
|
|
for file in files:
|
|
try:
|
|
file_content = await file.read()
|
|
|
|
media_file = await media_service.upload_file(
|
|
db=db,
|
|
vendor_id=current_user.token_vendor_id,
|
|
file_content=file_content,
|
|
filename=file.filename or "unnamed",
|
|
folder=folder or "general",
|
|
)
|
|
|
|
uploaded.append(UploadedFileInfo(
|
|
id=media_file.id,
|
|
filename=media_file.original_filename or media_file.filename,
|
|
file_url=media_file.file_url,
|
|
thumbnail_url=media_file.thumbnail_url,
|
|
))
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Failed to upload {file.filename}: {e}")
|
|
failed.append(FailedFileInfo(
|
|
filename=file.filename or "unnamed",
|
|
error=str(e),
|
|
))
|
|
|
|
db.commit()
|
|
|
|
return MultipleUploadResponse(
|
|
uploaded_files=uploaded,
|
|
failed_files=failed,
|
|
total_uploaded=len(uploaded),
|
|
total_failed=len(failed),
|
|
message=f"Uploaded {len(uploaded)} files, {len(failed)} failed",
|
|
)
|
|
|
|
|
|
@router.get("/{media_id}", response_model=MediaDetailResponse)
|
|
def get_media_details(
|
|
media_id: int,
|
|
current_user: User = Depends(get_current_vendor_api),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Get media file details.
|
|
|
|
- Get file metadata
|
|
- Return file URL
|
|
- Return basic info
|
|
"""
|
|
try:
|
|
media = media_service.get_media(
|
|
db=db,
|
|
vendor_id=current_user.token_vendor_id,
|
|
media_id=media_id,
|
|
)
|
|
|
|
return MediaDetailResponse.model_validate(media)
|
|
|
|
except MediaNotFoundException:
|
|
raise HTTPException(status_code=404, detail="Media file not found")
|
|
|
|
|
|
@router.put("/{media_id}", response_model=MediaDetailResponse)
|
|
def update_media_metadata(
|
|
media_id: int,
|
|
metadata: MediaMetadataUpdate,
|
|
current_user: User = Depends(get_current_vendor_api),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Update media file metadata.
|
|
|
|
- Update filename
|
|
- Update alt text
|
|
- Update description
|
|
- Move to different folder
|
|
"""
|
|
try:
|
|
media = media_service.update_media_metadata(
|
|
db=db,
|
|
vendor_id=current_user.token_vendor_id,
|
|
media_id=media_id,
|
|
filename=metadata.filename,
|
|
alt_text=metadata.alt_text,
|
|
description=metadata.description,
|
|
folder=metadata.folder,
|
|
metadata=metadata.metadata,
|
|
)
|
|
|
|
db.commit()
|
|
|
|
return MediaDetailResponse.model_validate(media)
|
|
|
|
except MediaNotFoundException:
|
|
raise HTTPException(status_code=404, detail="Media file not found")
|
|
except Exception as e:
|
|
db.rollback()
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
@router.delete("/{media_id}", response_model=MediaDetailResponse)
|
|
def delete_media(
|
|
media_id: int,
|
|
current_user: User = Depends(get_current_vendor_api),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Delete media file.
|
|
|
|
- Verify file belongs to vendor
|
|
- Delete file from storage
|
|
- Delete database record
|
|
- Return success/error
|
|
"""
|
|
try:
|
|
media_service.delete_media(
|
|
db=db,
|
|
vendor_id=current_user.token_vendor_id,
|
|
media_id=media_id,
|
|
)
|
|
|
|
db.commit()
|
|
|
|
return MediaDetailResponse(message="Media file deleted successfully")
|
|
|
|
except MediaNotFoundException:
|
|
raise HTTPException(status_code=404, detail="Media file not found")
|
|
except Exception as e:
|
|
db.rollback()
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
@router.get("/{media_id}/usage", response_model=MediaUsageResponse)
|
|
def get_media_usage(
|
|
media_id: int,
|
|
current_user: User = Depends(get_current_vendor_api),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Get where this media file is being used.
|
|
|
|
- Check products using this media
|
|
- Return list of usage
|
|
"""
|
|
try:
|
|
usage = media_service.get_media_usage(
|
|
db=db,
|
|
vendor_id=current_user.token_vendor_id,
|
|
media_id=media_id,
|
|
)
|
|
|
|
return MediaUsageResponse(**usage)
|
|
|
|
except MediaNotFoundException:
|
|
raise HTTPException(status_code=404, detail="Media file not found")
|
|
|
|
|
|
@router.post("/optimize/{media_id}", response_model=OptimizationResultResponse)
|
|
def optimize_media(
|
|
media_id: int,
|
|
current_user: User = Depends(get_current_vendor_api),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Optimize media file (compress, resize, etc.).
|
|
|
|
Note: Image optimization requires PIL/Pillow to be installed.
|
|
"""
|
|
try:
|
|
media = media_service.get_media(
|
|
db=db,
|
|
vendor_id=current_user.token_vendor_id,
|
|
media_id=media_id,
|
|
)
|
|
|
|
if media.media_type != "image":
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Only images can be optimized"
|
|
)
|
|
|
|
# For now, return current state - optimization is done on upload
|
|
return OptimizationResultResponse(
|
|
media_id=media_id,
|
|
original_size=media.file_size,
|
|
optimized_size=media.optimized_size or media.file_size,
|
|
savings_percent=0.0 if not media.optimized_size else
|
|
round((1 - media.optimized_size / media.file_size) * 100, 1),
|
|
optimized_url=media.file_url,
|
|
message="Image optimization applied on upload" if media.is_optimized
|
|
else "Image not yet optimized",
|
|
)
|
|
|
|
except MediaNotFoundException:
|
|
raise HTTPException(status_code=404, detail="Media file not found")
|