fix: resolve architecture validation errors in media and customers APIs

- Add proper media exceptions (MediaNotFoundException, MediaUploadException, etc.)
- Update media service to use exceptions from app/exceptions/media
- Remove direct HTTPException raises from vendor/media.py and vendor/customers.py
- Move db.query from customers API to service layer (get_customer_orders)
- Fix pagination macro call in vendor/media.html template
- Update media.js: add parent init call, PlatformSettings, apiClient.postFormData
- Add try/catch error handling to media.js init method

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-06 22:40:10 +01:00
parent 8ee3f91467
commit 7b81f59eba
7 changed files with 397 additions and 248 deletions

View File

@@ -8,14 +8,12 @@ The get_current_vendor_api dependency guarantees token_vendor_id is present.
import logging import logging
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api from app.api.deps import get_current_vendor_api
from app.core.database import get_db from app.core.database import get_db
from app.exceptions.customer import CustomerNotFoundException
from app.services.customer_service import customer_service from app.services.customer_service import customer_service
from models.database.order import Order
from models.database.user import User from models.database.user import User
from models.schema.customer import ( from models.schema.customer import (
CustomerDetailResponse, CustomerDetailResponse,
@@ -78,38 +76,35 @@ def get_customer_details(
- Verify customer belongs to vendor - Verify customer belongs to vendor
- Include order statistics - Include order statistics
""" """
try: # Service will raise CustomerNotFoundException if not found
customer = customer_service.get_customer( customer = customer_service.get_customer(
db=db, db=db,
vendor_id=current_user.token_vendor_id, vendor_id=current_user.token_vendor_id,
customer_id=customer_id, customer_id=customer_id,
) )
# Get statistics # Get statistics
stats = customer_service.get_customer_statistics( stats = customer_service.get_customer_statistics(
db=db, db=db,
vendor_id=current_user.token_vendor_id, vendor_id=current_user.token_vendor_id,
customer_id=customer_id, customer_id=customer_id,
) )
return CustomerDetailResponse( return CustomerDetailResponse(
id=customer.id, id=customer.id,
email=customer.email, email=customer.email,
first_name=customer.first_name, first_name=customer.first_name,
last_name=customer.last_name, last_name=customer.last_name,
phone=customer.phone, phone=customer.phone,
customer_number=customer.customer_number, customer_number=customer.customer_number,
is_active=customer.is_active, is_active=customer.is_active,
marketing_consent=customer.marketing_consent, marketing_consent=customer.marketing_consent,
total_orders=stats["total_orders"], total_orders=stats["total_orders"],
total_spent=stats["total_spent"], total_spent=stats["total_spent"],
average_order_value=stats["average_order_value"], average_order_value=stats["average_order_value"],
last_order_date=stats["last_order_date"], last_order_date=stats["last_order_date"],
created_at=customer.created_at, created_at=customer.created_at,
) )
except CustomerNotFoundException:
raise HTTPException(status_code=404, detail="Customer not found")
@router.get("/{customer_id}/orders", response_model=CustomerOrdersResponse) @router.get("/{customer_id}/orders", response_model=CustomerOrdersResponse)
@@ -127,45 +122,30 @@ def get_customer_orders(
- Filter by vendor_id - Filter by vendor_id
- Return order details - Return order details
""" """
try: # Service will raise CustomerNotFoundException if not found
# Verify customer belongs to vendor orders, total = customer_service.get_customer_orders(
customer_service.get_customer( db=db,
db=db, vendor_id=current_user.token_vendor_id,
vendor_id=current_user.token_vendor_id, customer_id=customer_id,
customer_id=customer_id, skip=skip,
) limit=limit,
)
# Get customer orders return CustomerOrdersResponse(
query = ( orders=[
db.query(Order) {
.filter( "id": o.id,
Order.customer_id == customer_id, "order_number": o.order_number,
Order.vendor_id == current_user.token_vendor_id, "status": o.status,
) "total": o.total_cents / 100 if o.total_cents else 0,
.order_by(Order.created_at.desc()) "created_at": o.created_at,
) }
for o in orders
total = query.count() ],
orders = query.offset(skip).limit(limit).all() total=total,
skip=skip,
return CustomerOrdersResponse( limit=limit,
orders=[ )
{
"id": o.id,
"order_number": o.order_number,
"status": o.status,
"total": o.total_cents / 100 if o.total_cents else 0,
"created_at": o.created_at,
}
for o in orders
],
total=total,
skip=skip,
limit=limit,
)
except CustomerNotFoundException:
raise HTTPException(status_code=404, detail="Customer not found")
@router.put("/{customer_id}", response_model=CustomerMessageResponse) @router.put("/{customer_id}", response_model=CustomerMessageResponse)
@@ -181,23 +161,17 @@ def update_customer(
- Update customer details - Update customer details
- Verify customer belongs to vendor - Verify customer belongs to vendor
""" """
try: # Service will raise CustomerNotFoundException if not found
customer_service.update_customer( customer_service.update_customer(
db=db, db=db,
vendor_id=current_user.token_vendor_id, vendor_id=current_user.token_vendor_id,
customer_id=customer_id, customer_id=customer_id,
customer_data=customer_data, customer_data=customer_data,
) )
db.commit() db.commit()
return CustomerMessageResponse(message="Customer updated successfully") return CustomerMessageResponse(message="Customer updated successfully")
except CustomerNotFoundException:
raise HTTPException(status_code=404, detail="Customer not found")
except Exception as e:
db.rollback()
raise HTTPException(status_code=400, detail=str(e))
@router.put("/{customer_id}/status", response_model=CustomerMessageResponse) @router.put("/{customer_id}/status", response_model=CustomerMessageResponse)
@@ -212,23 +186,17 @@ def toggle_customer_status(
- Toggle customer is_active status - Toggle customer is_active status
- Verify customer belongs to vendor - Verify customer belongs to vendor
""" """
try: # Service will raise CustomerNotFoundException if not found
customer = customer_service.toggle_customer_status( customer = customer_service.toggle_customer_status(
db=db, db=db,
vendor_id=current_user.token_vendor_id, vendor_id=current_user.token_vendor_id,
customer_id=customer_id, customer_id=customer_id,
) )
db.commit() db.commit()
status = "activated" if customer.is_active else "deactivated" status = "activated" if customer.is_active else "deactivated"
return CustomerMessageResponse(message=f"Customer {status} successfully") return CustomerMessageResponse(message=f"Customer {status} successfully")
except CustomerNotFoundException:
raise HTTPException(status_code=404, detail="Customer not found")
except Exception as e:
db.rollback()
raise HTTPException(status_code=400, detail=str(e))
@router.get("/{customer_id}/stats", response_model=CustomerStatisticsResponse) @router.get("/{customer_id}/stats", response_model=CustomerStatisticsResponse)
@@ -245,14 +213,11 @@ def get_customer_statistics(
- Average order value - Average order value
- Last order date - Last order date
""" """
try: # Service will raise CustomerNotFoundException if not found
stats = customer_service.get_customer_statistics( stats = customer_service.get_customer_statistics(
db=db, db=db,
vendor_id=current_user.token_vendor_id, vendor_id=current_user.token_vendor_id,
customer_id=customer_id, customer_id=customer_id,
) )
return CustomerStatisticsResponse(**stats) return CustomerStatisticsResponse(**stats)
except CustomerNotFoundException:
raise HTTPException(status_code=404, detail="Customer not found")

View File

@@ -8,12 +8,13 @@ The get_current_vendor_api dependency guarantees token_vendor_id is present.
import logging import logging
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile from fastapi import APIRouter, Depends, File, Query, UploadFile
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api from app.api.deps import get_current_vendor_api
from app.core.database import get_db from app.core.database import get_db
from app.services.media_service import MediaNotFoundException, media_service from app.exceptions.media import MediaOptimizationException
from app.services.media_service import media_service
from models.database.user import User from models.database.user import User
from models.schema.media import ( from models.schema.media import (
MediaDetailResponse, MediaDetailResponse,
@@ -86,35 +87,29 @@ async def upload_media(
- Save metadata to database - Save metadata to database
- Return file URL - Return file URL
""" """
try: # Read file content
# Read file content file_content = await file.read()
file_content = await file.read()
# Upload using service # Upload using service (exceptions will propagate to handler)
media_file = await media_service.upload_file( media_file = await media_service.upload_file(
db=db, db=db,
vendor_id=current_user.token_vendor_id, vendor_id=current_user.token_vendor_id,
file_content=file_content, file_content=file_content,
filename=file.filename or "unnamed", filename=file.filename or "unnamed",
folder=folder or "general", folder=folder or "general",
) )
db.commit() db.commit()
return MediaUploadResponse( return MediaUploadResponse(
id=media_file.id, id=media_file.id,
file_url=media_file.file_url, file_url=media_file.file_url,
thumbnail_url=media_file.thumbnail_url, thumbnail_url=media_file.thumbnail_url,
filename=media_file.original_filename, filename=media_file.original_filename,
file_size=media_file.file_size, file_size=media_file.file_size,
media_type=media_file.media_type, media_type=media_file.media_type,
message="File uploaded successfully", 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) @router.post("/upload/multiple", response_model=MultipleUploadResponse)
@@ -185,17 +180,14 @@ def get_media_details(
- Return file URL - Return file URL
- Return basic info - Return basic info
""" """
try: # Service will raise MediaNotFoundException if not found
media = media_service.get_media( media = media_service.get_media(
db=db, db=db,
vendor_id=current_user.token_vendor_id, vendor_id=current_user.token_vendor_id,
media_id=media_id, media_id=media_id,
) )
return MediaDetailResponse.model_validate(media) return MediaDetailResponse.model_validate(media)
except MediaNotFoundException:
raise HTTPException(status_code=404, detail="Media file not found")
@router.put("/{media_id}", response_model=MediaDetailResponse) @router.put("/{media_id}", response_model=MediaDetailResponse)
@@ -213,27 +205,21 @@ def update_media_metadata(
- Update description - Update description
- Move to different folder - Move to different folder
""" """
try: # Service will raise MediaNotFoundException if not found
media = media_service.update_media_metadata( media = media_service.update_media_metadata(
db=db, db=db,
vendor_id=current_user.token_vendor_id, vendor_id=current_user.token_vendor_id,
media_id=media_id, media_id=media_id,
filename=metadata.filename, filename=metadata.filename,
alt_text=metadata.alt_text, alt_text=metadata.alt_text,
description=metadata.description, description=metadata.description,
folder=metadata.folder, folder=metadata.folder,
metadata=metadata.metadata, metadata=metadata.metadata,
) )
db.commit() db.commit()
return MediaDetailResponse.model_validate(media) 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) @router.delete("/{media_id}", response_model=MediaDetailResponse)
@@ -250,22 +236,16 @@ def delete_media(
- Delete database record - Delete database record
- Return success/error - Return success/error
""" """
try: # Service will raise MediaNotFoundException if not found
media_service.delete_media( media_service.delete_media(
db=db, db=db,
vendor_id=current_user.token_vendor_id, vendor_id=current_user.token_vendor_id,
media_id=media_id, media_id=media_id,
) )
db.commit() db.commit()
return MediaDetailResponse(message="Media file deleted successfully") 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) @router.get("/{media_id}/usage", response_model=MediaUsageResponse)
@@ -280,17 +260,14 @@ def get_media_usage(
- Check products using this media - Check products using this media
- Return list of usage - Return list of usage
""" """
try: # Service will raise MediaNotFoundException if not found
usage = media_service.get_media_usage( usage = media_service.get_media_usage(
db=db, db=db,
vendor_id=current_user.token_vendor_id, vendor_id=current_user.token_vendor_id,
media_id=media_id, media_id=media_id,
) )
return MediaUsageResponse(**usage) return MediaUsageResponse(**usage)
except MediaNotFoundException:
raise HTTPException(status_code=404, detail="Media file not found")
@router.post("/optimize/{media_id}", response_model=OptimizationResultResponse) @router.post("/optimize/{media_id}", response_model=OptimizationResultResponse)
@@ -304,30 +281,24 @@ def optimize_media(
Note: Image optimization requires PIL/Pillow to be installed. Note: Image optimization requires PIL/Pillow to be installed.
""" """
try: # Service will raise MediaNotFoundException if not found
media = media_service.get_media( media = media_service.get_media(
db=db, db=db,
vendor_id=current_user.token_vendor_id, vendor_id=current_user.token_vendor_id,
media_id=media_id, media_id=media_id,
) )
if media.media_type != "image": if media.media_type != "image":
raise HTTPException( raise MediaOptimizationException("Only images can be optimized")
status_code=400,
detail="Only images can be optimized"
)
# For now, return current state - optimization is done on upload # For now, return current state - optimization is done on upload
return OptimizationResultResponse( return OptimizationResultResponse(
media_id=media_id, media_id=media_id,
original_size=media.file_size, original_size=media.file_size,
optimized_size=media.optimized_size or media.file_size, optimized_size=media.optimized_size or media.file_size,
savings_percent=0.0 if not media.optimized_size else savings_percent=0.0 if not media.optimized_size else
round((1 - media.optimized_size / media.file_size) * 100, 1), round((1 - media.optimized_size / media.file_size) * 100, 1),
optimized_url=media.file_url, optimized_url=media.file_url,
message="Image optimization applied on upload" if media.is_optimized message="Image optimization applied on upload" if media.is_optimized
else "Image not yet optimized", else "Image not yet optimized",
) )
except MediaNotFoundException:
raise HTTPException(status_code=404, detail="Media file not found")

View File

@@ -1 +1,101 @@
# Media/file management exceptions # app/exceptions/media.py
"""
Media/file management exceptions.
"""
from typing import Any
from .base import (
BusinessLogicException,
ResourceNotFoundException,
ValidationException,
)
class MediaNotFoundException(ResourceNotFoundException):
"""Raised when a media file is not found."""
def __init__(self, media_id: int | str):
super().__init__(
resource_type="MediaFile",
identifier=str(media_id),
message=f"Media file '{media_id}' not found",
error_code="MEDIA_NOT_FOUND",
)
class MediaUploadException(BusinessLogicException):
"""Raised when media upload fails."""
def __init__(self, message: str, details: dict[str, Any] | None = None):
super().__init__(
message=message,
error_code="MEDIA_UPLOAD_FAILED",
details=details,
)
class MediaValidationException(ValidationException):
"""Raised when media validation fails (file type, size, etc.)."""
def __init__(
self,
message: str = "Media validation failed",
field: str | None = None,
details: dict[str, Any] | None = None,
):
super().__init__(message=message, field=field, details=details)
self.error_code = "MEDIA_VALIDATION_FAILED"
class UnsupportedMediaTypeException(ValidationException):
"""Raised when media file type is not supported."""
def __init__(self, file_type: str, allowed_types: list[str] | None = None):
details = {"file_type": file_type}
if allowed_types:
details["allowed_types"] = allowed_types
super().__init__(
message=f"Unsupported media type: {file_type}",
field="file",
details=details,
)
self.error_code = "UNSUPPORTED_MEDIA_TYPE"
class MediaFileTooLargeException(ValidationException):
"""Raised when media file exceeds size limit."""
def __init__(self, file_size: int, max_size: int, media_type: str = "file"):
super().__init__(
message=f"File size ({file_size} bytes) exceeds maximum allowed ({max_size} bytes) for {media_type}",
field="file",
details={
"file_size": file_size,
"max_size": max_size,
"media_type": media_type,
},
)
self.error_code = "MEDIA_FILE_TOO_LARGE"
class MediaOptimizationException(BusinessLogicException):
"""Raised when media optimization fails."""
def __init__(self, message: str = "Only images can be optimized"):
super().__init__(
message=message,
error_code="MEDIA_OPTIMIZATION_FAILED",
)
class MediaDeleteException(BusinessLogicException):
"""Raised when media deletion fails."""
def __init__(self, message: str, details: dict[str, Any] | None = None):
super().__init__(
message=message,
error_code="MEDIA_DELETE_FAILED",
details=details,
)

View File

@@ -310,6 +310,50 @@ class CustomerService:
return customers, total return customers, total
def get_customer_orders(
self,
db: Session,
vendor_id: int,
customer_id: int,
skip: int = 0,
limit: int = 50,
) -> tuple[list, int]:
"""
Get orders for a specific customer.
Args:
db: Database session
vendor_id: Vendor ID
customer_id: Customer ID
skip: Pagination offset
limit: Pagination limit
Returns:
Tuple of (orders, total_count)
Raises:
CustomerNotFoundException: If customer not found
"""
from models.database.order import Order
# Verify customer belongs to vendor
self.get_customer(db, vendor_id, customer_id)
# Get customer orders
query = (
db.query(Order)
.filter(
Order.customer_id == customer_id,
Order.vendor_id == vendor_id,
)
.order_by(Order.created_at.desc())
)
total = query.count()
orders = query.offset(skip).limit(limit).all()
return orders, total
def get_customer_statistics( def get_customer_statistics(
self, db: Session, vendor_id: int, customer_id: int self, db: Session, vendor_id: int, customer_id: int
) -> dict: ) -> dict:

View File

@@ -20,7 +20,13 @@ from pathlib import Path
from sqlalchemy import or_ from sqlalchemy import or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.exceptions import ValidationException from app.exceptions.media import (
MediaNotFoundException,
MediaUploadException,
MediaValidationException,
UnsupportedMediaTypeException,
MediaFileTooLargeException,
)
from models.database.media import MediaFile, ProductMedia from models.database.media import MediaFile, ProductMedia
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -63,14 +69,6 @@ MAX_FILE_SIZES = {
THUMBNAIL_SIZE = (200, 200) THUMBNAIL_SIZE = (200, 200)
class MediaNotFoundException(Exception):
"""Raised when media file is not found."""
def __init__(self, media_id: int):
self.media_id = media_id
super().__init__(f"Media file {media_id} not found")
class MediaService: class MediaService:
"""Service for vendor media library operations.""" """Service for vendor media library operations."""
@@ -105,26 +103,24 @@ class MediaService:
Tuple of (extension, media_type) Tuple of (extension, media_type)
Raises: Raises:
ValidationException: If file is invalid MediaValidationException: If file is invalid
UnsupportedMediaTypeException: If file type is not supported
MediaFileTooLargeException: If file exceeds size limit
""" """
ext = self._get_file_extension(filename) ext = self._get_file_extension(filename)
if not ext: if not ext:
raise ValidationException("File must have an extension") raise MediaValidationException("File must have an extension", field="file")
media_type = self._get_media_type(ext) media_type = self._get_media_type(ext)
if not media_type: if not media_type:
allowed = ", ".join(sorted(ALLOWED_EXTENSIONS.keys())) raise UnsupportedMediaTypeException(
raise ValidationException( ext, allowed_types=list(ALLOWED_EXTENSIONS.keys())
f"File type '{ext}' not allowed. Allowed types: {allowed}"
) )
max_size = MAX_FILE_SIZES.get(media_type, 10 * 1024 * 1024) max_size = MAX_FILE_SIZES.get(media_type, 10 * 1024 * 1024)
if file_size > max_size: if file_size > max_size:
max_mb = max_size / (1024 * 1024) raise MediaFileTooLargeException(file_size, max_size, media_type)
raise ValidationException(
f"File too large. Maximum size for {media_type} is {max_mb:.0f} MB"
)
return ext, media_type return ext, media_type

View File

@@ -199,7 +199,7 @@
<!-- Pagination --> <!-- Pagination -->
<div x-show="pagination.pages > 1" class="mt-6"> <div x-show="pagination.pages > 1" class="mt-6">
{{ pagination('pagination', 'loadMedia') }} {{ pagination('pagination.pages > 1') }}
</div> </div>
</div> </div>

View File

@@ -48,6 +48,60 @@ function vendorMedia() {
pages: 0 pages: 0
}, },
// Computed pagination properties required by pagination macro
get startIndex() {
if (this.pagination.total === 0) return 0;
return (this.pagination.page - 1) * this.pagination.per_page + 1;
},
get endIndex() {
return Math.min(this.pagination.page * this.pagination.per_page, this.pagination.total);
},
get totalPages() {
return this.pagination.pages;
},
get pageNumbers() {
const pages = [];
const total = this.pagination.pages;
const current = this.pagination.page;
if (total <= 7) {
for (let i = 1; i <= total; i++) pages.push(i);
} else {
pages.push(1);
if (current > 3) pages.push('...');
for (let i = Math.max(2, current - 1); i <= Math.min(total - 1, current + 1); i++) {
pages.push(i);
}
if (current < total - 2) pages.push('...');
pages.push(total);
}
return pages;
},
previousPage() {
if (this.pagination.page > 1) {
this.pagination.page--;
this.loadMedia();
}
},
nextPage() {
if (this.pagination.page < this.pagination.pages) {
this.pagination.page++;
this.loadMedia();
}
},
goToPage(pageNum) {
if (pageNum !== '...' && pageNum >= 1 && pageNum <= this.pagination.pages) {
this.pagination.page = pageNum;
this.loadMedia();
}
},
// Modal states // Modal states
showUploadModal: false, showUploadModal: false,
showDetailModal: false, showDetailModal: false,
@@ -65,8 +119,30 @@ function vendorMedia() {
uploadingFiles: [], uploadingFiles: [],
async init() { async init() {
// Guard against duplicate initialization
if (window._vendorMediaInitialized) return;
window._vendorMediaInitialized = true;
vendorMediaLog.info('Initializing media library...'); vendorMediaLog.info('Initializing media library...');
await this.loadMedia();
try {
// IMPORTANT: Call parent init first to set vendorCode from URL
const parentInit = data().init;
if (parentInit) {
await parentInit.call(this);
}
// Initialize pagination per_page from PlatformSettings
if (window.PlatformSettings) {
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
}
await this.loadMedia();
} catch (err) {
vendorMediaLog.error('Failed to initialize media library:', err);
this.error = err.message || 'Failed to initialize media library';
this.loading = false;
}
}, },
async loadMedia() { async loadMedia() {
@@ -231,22 +307,19 @@ function vendorMedia() {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
const response = await fetch(`/api/v1/vendor/media/upload?folder=${this.uploadFolder}`, { // Use apiClient.postFormData for automatic auth handling
method: 'POST', const response = await apiClient.postFormData(
headers: { `/vendor/media/upload?folder=${this.uploadFolder}`,
'Authorization': `Bearer ${localStorage.getItem('vendor_token')}` formData
}, );
body: formData
});
if (response.ok) { if (response.ok) {
uploadItem.status = 'success'; uploadItem.status = 'success';
vendorMediaLog.info(`Uploaded: ${file.name}`); vendorMediaLog.info(`Uploaded: ${file.name}`);
} else { } else {
const errorData = await response.json();
uploadItem.status = 'error'; uploadItem.status = 'error';
uploadItem.error = errorData.detail || 'Upload failed'; uploadItem.error = response.message || 'Upload failed';
vendorMediaLog.error(`Upload failed for ${file.name}:`, errorData); vendorMediaLog.error(`Upload failed for ${file.name}:`, response);
} }
} catch (err) { } catch (err) {
uploadItem.status = 'error'; uploadItem.status = 'error';