Files
bakery-ia/services/suppliers/app/api/suppliers.py
2025-10-29 06:58:05 +01:00

613 lines
22 KiB
Python

# services/suppliers/app/api/suppliers.py
"""
Supplier CRUD API endpoints (ATOMIC)
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Path
from typing import List, Optional, Dict, Any
from uuid import UUID
import structlog
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import get_db
from app.services.supplier_service import SupplierService
from app.models.suppliers import SupplierPriceList
from app.models import AuditLog
from app.schemas.suppliers import (
SupplierCreate, SupplierUpdate, SupplierResponse, SupplierSummary,
SupplierSearchParams, SupplierDeletionSummary,
SupplierPriceListCreate, SupplierPriceListUpdate, SupplierPriceListResponse
)
from shared.auth.decorators import get_current_user_dep
from shared.routing import RouteBuilder
from shared.auth.access_control import require_user_role
from shared.security import create_audit_logger, AuditSeverity, AuditAction
# Create route builder for consistent URL structure
route_builder = RouteBuilder('suppliers')
router = APIRouter(tags=["suppliers"])
logger = structlog.get_logger()
audit_logger = create_audit_logger("suppliers-service", AuditLog)
@router.post(route_builder.build_base_route(""), response_model=SupplierResponse)
@require_user_role(['admin', 'owner', 'member'])
async def create_supplier(
supplier_data: SupplierCreate,
tenant_id: str = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Create a new supplier"""
try:
service = SupplierService(db)
# Get user role from current_user dict
user_role = current_user.get("role", "member").lower()
supplier = await service.create_supplier(
tenant_id=UUID(tenant_id),
supplier_data=supplier_data,
created_by=current_user["user_id"],
created_by_role=user_role
)
return SupplierResponse.from_orm(supplier)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error("Error creating supplier", error=str(e))
raise HTTPException(status_code=500, detail="Failed to create supplier")
@router.get(route_builder.build_base_route(""), response_model=List[SupplierSummary])
async def list_suppliers(
tenant_id: str = Path(..., description="Tenant ID"),
search_term: Optional[str] = Query(None, description="Search term"),
supplier_type: Optional[str] = Query(None, description="Supplier type filter"),
status: Optional[str] = Query(None, description="Status filter"),
limit: int = Query(50, ge=1, le=1000, description="Number of results to return"),
offset: int = Query(0, ge=0, description="Number of results to skip"),
db: AsyncSession = Depends(get_db)
):
"""List suppliers with optional filters"""
try:
service = SupplierService(db)
search_params = SupplierSearchParams(
search_term=search_term,
supplier_type=supplier_type,
status=status,
limit=limit,
offset=offset
)
suppliers = await service.search_suppliers(
tenant_id=UUID(tenant_id),
search_params=search_params
)
return [SupplierSummary.from_orm(supplier) for supplier in suppliers]
except Exception as e:
logger.error("Error listing suppliers", error=str(e))
raise HTTPException(status_code=500, detail="Failed to retrieve suppliers")
@router.get(route_builder.build_resource_detail_route("", "supplier_id"), response_model=SupplierResponse)
async def get_supplier(
supplier_id: UUID = Path(..., description="Supplier ID"),
tenant_id: str = Path(..., description="Tenant ID"),
db: AsyncSession = Depends(get_db)
):
"""Get supplier by ID"""
try:
service = SupplierService(db)
supplier = await service.get_supplier(supplier_id)
if not supplier:
raise HTTPException(status_code=404, detail="Supplier not found")
return SupplierResponse.from_orm(supplier)
except HTTPException:
raise
except Exception as e:
logger.error("Error getting supplier", supplier_id=str(supplier_id), error=str(e))
raise HTTPException(status_code=500, detail="Failed to retrieve supplier")
@router.put(route_builder.build_resource_detail_route("", "supplier_id"), response_model=SupplierResponse)
@require_user_role(['admin', 'owner', 'member'])
async def update_supplier(
supplier_data: SupplierUpdate,
supplier_id: UUID = Path(..., description="Supplier ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Update supplier information"""
try:
service = SupplierService(db)
# Check supplier exists
existing_supplier = await service.get_supplier(supplier_id)
if not existing_supplier:
raise HTTPException(status_code=404, detail="Supplier not found")
supplier = await service.update_supplier(
supplier_id=supplier_id,
supplier_data=supplier_data,
updated_by=current_user["user_id"]
)
if not supplier:
raise HTTPException(status_code=404, detail="Supplier not found")
return SupplierResponse.from_orm(supplier)
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error("Error updating supplier", supplier_id=str(supplier_id), error=str(e))
raise HTTPException(status_code=500, detail="Failed to update supplier")
@router.delete(route_builder.build_resource_detail_route("", "supplier_id"))
@require_user_role(['admin', 'owner'])
async def delete_supplier(
supplier_id: UUID = Path(..., description="Supplier ID"),
tenant_id: str = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Delete supplier (soft delete, Admin+ only)"""
try:
service = SupplierService(db)
# Check supplier exists
existing_supplier = await service.get_supplier(supplier_id)
if not existing_supplier:
raise HTTPException(status_code=404, detail="Supplier not found")
# Capture supplier data before deletion
supplier_data = {
"supplier_name": existing_supplier.name,
"supplier_type": existing_supplier.supplier_type,
"contact_person": existing_supplier.contact_person,
"email": existing_supplier.email
}
success = await service.delete_supplier(supplier_id)
if not success:
raise HTTPException(status_code=404, detail="Supplier not found")
# Log audit event for supplier deletion
try:
# Get sync db session for audit logging
from app.core.database import SessionLocal
sync_db = SessionLocal()
try:
await audit_logger.log_deletion(
db_session=sync_db,
tenant_id=tenant_id,
user_id=current_user["user_id"],
resource_type="supplier",
resource_id=str(supplier_id),
resource_data=supplier_data,
description=f"Admin {current_user.get('email', 'unknown')} deleted supplier",
endpoint=f"/suppliers/{supplier_id}",
method="DELETE"
)
sync_db.commit()
finally:
sync_db.close()
except Exception as audit_error:
logger.warning("Failed to log audit event", error=str(audit_error))
logger.info("Deleted supplier",
supplier_id=str(supplier_id),
tenant_id=tenant_id,
user_id=current_user["user_id"])
return {"message": "Supplier deleted successfully"}
except HTTPException:
raise
except Exception as e:
logger.error("Error deleting supplier", supplier_id=str(supplier_id), error=str(e))
raise HTTPException(status_code=500, detail="Failed to delete supplier")
@router.delete(
route_builder.build_resource_action_route("", "supplier_id", "hard"),
response_model=SupplierDeletionSummary
)
@require_user_role(['admin', 'owner'])
async def hard_delete_supplier(
supplier_id: UUID = Path(..., description="Supplier ID"),
tenant_id: str = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Hard delete supplier and all associated data (Admin/Owner only, permanent)"""
try:
service = SupplierService(db)
# Check supplier exists
existing_supplier = await service.get_supplier(supplier_id)
if not existing_supplier:
raise HTTPException(status_code=404, detail="Supplier not found")
# Capture supplier data before deletion
supplier_data = {
"id": str(existing_supplier.id),
"name": existing_supplier.name,
"status": existing_supplier.status.value,
"supplier_code": existing_supplier.supplier_code
}
# Perform hard deletion
deletion_summary = await service.hard_delete_supplier(supplier_id, UUID(tenant_id))
# Log audit event for hard deletion
try:
# Get sync db session for audit logging
from app.core.database import SessionLocal
sync_db = SessionLocal()
try:
await audit_logger.log_deletion(
db_session=sync_db,
tenant_id=tenant_id,
user_id=current_user["user_id"],
resource_type="supplier",
resource_id=str(supplier_id),
resource_data=supplier_data,
description=f"Hard deleted supplier '{supplier_data['name']}' and all associated data",
endpoint=f"/suppliers/{supplier_id}/hard",
method="DELETE",
metadata=deletion_summary
)
sync_db.commit()
finally:
sync_db.close()
except Exception as audit_error:
logger.warning("Failed to log audit event", error=str(audit_error))
logger.info("Hard deleted supplier",
supplier_id=str(supplier_id),
tenant_id=tenant_id,
user_id=current_user["user_id"],
deletion_summary=deletion_summary)
return deletion_summary
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error("Error hard deleting supplier", supplier_id=str(supplier_id), error=str(e))
raise HTTPException(status_code=500, detail="Failed to hard delete supplier")
@router.get(
route_builder.build_base_route("count"),
response_model=dict
)
async def count_suppliers(
tenant_id: str = Path(..., description="Tenant ID"),
db: AsyncSession = Depends(get_db)
):
"""Get count of suppliers for a tenant"""
try:
service = SupplierService(db)
# Use search with maximum allowed limit to get all suppliers
search_params = SupplierSearchParams(limit=1000)
suppliers = await service.search_suppliers(
tenant_id=UUID(tenant_id),
search_params=search_params
)
count = len(suppliers)
logger.info("Retrieved supplier count", tenant_id=tenant_id, count=count)
return {"count": count}
except Exception as e:
logger.error("Error counting suppliers", tenant_id=tenant_id, error=str(e))
raise HTTPException(status_code=500, detail="Failed to count suppliers")
@router.get(
route_builder.build_resource_action_route("", "supplier_id", "products"),
response_model=List[Dict[str, Any]]
)
async def get_supplier_products(
supplier_id: UUID = Path(..., description="Supplier ID"),
tenant_id: str = Path(..., description="Tenant ID"),
is_active: bool = Query(True, description="Filter by active price lists"),
db: AsyncSession = Depends(get_db)
):
"""
Get list of product IDs that a supplier provides
Returns a list of inventory product IDs from the supplier's price list
"""
try:
# Query supplier price lists
query = select(SupplierPriceList).where(
SupplierPriceList.tenant_id == UUID(tenant_id),
SupplierPriceList.supplier_id == supplier_id
)
if is_active:
query = query.where(SupplierPriceList.is_active == True)
result = await db.execute(query)
price_lists = result.scalars().all()
# Extract unique product IDs
product_ids = list(set([str(pl.inventory_product_id) for pl in price_lists]))
logger.info(
"Retrieved supplier products",
supplier_id=str(supplier_id),
product_count=len(product_ids)
)
return [{"inventory_product_id": pid} for pid in product_ids]
except Exception as e:
logger.error(
"Error getting supplier products",
supplier_id=str(supplier_id),
error=str(e)
)
raise HTTPException(
status_code=500,
detail="Failed to retrieve supplier products"
)
@router.get(
route_builder.build_resource_action_route("", "supplier_id", "price-lists"),
response_model=List[SupplierPriceListResponse]
)
async def get_supplier_price_lists(
supplier_id: UUID = Path(..., description="Supplier ID"),
tenant_id: str = Path(..., description="Tenant ID"),
is_active: bool = Query(True, description="Filter by active price lists"),
db: AsyncSession = Depends(get_db)
):
"""Get all price list items for a supplier"""
try:
service = SupplierService(db)
price_lists = await service.get_supplier_price_lists(
supplier_id=supplier_id,
tenant_id=UUID(tenant_id),
is_active=is_active
)
logger.info(
"Retrieved supplier price lists",
supplier_id=str(supplier_id),
count=len(price_lists)
)
return [SupplierPriceListResponse.from_orm(pl) for pl in price_lists]
except Exception as e:
logger.error(
"Error getting supplier price lists",
supplier_id=str(supplier_id),
error=str(e)
)
raise HTTPException(
status_code=500,
detail="Failed to retrieve supplier price lists"
)
@router.get(
route_builder.build_resource_action_route("", "supplier_id", "price-lists/{price_list_id}"),
response_model=SupplierPriceListResponse
)
async def get_supplier_price_list(
supplier_id: UUID = Path(..., description="Supplier ID"),
price_list_id: UUID = Path(..., description="Price List ID"),
tenant_id: str = Path(..., description="Tenant ID"),
db: AsyncSession = Depends(get_db)
):
"""Get specific price list item for a supplier"""
try:
service = SupplierService(db)
price_list = await service.get_supplier_price_list(
price_list_id=price_list_id,
tenant_id=UUID(tenant_id)
)
if not price_list:
raise HTTPException(status_code=404, detail="Price list item not found")
logger.info(
"Retrieved supplier price list item",
supplier_id=str(supplier_id),
price_list_id=str(price_list_id)
)
return SupplierPriceListResponse.from_orm(price_list)
except HTTPException:
raise
except Exception as e:
logger.error(
"Error getting supplier price list item",
supplier_id=str(supplier_id),
price_list_id=str(price_list_id),
error=str(e)
)
raise HTTPException(
status_code=500,
detail="Failed to retrieve supplier price list item"
)
@router.post(
route_builder.build_resource_action_route("", "supplier_id", "price-lists"),
response_model=SupplierPriceListResponse
)
@require_user_role(['admin', 'owner', 'member'])
async def create_supplier_price_list(
supplier_id: UUID = Path(..., description="Supplier ID"),
price_list_data: SupplierPriceListCreate = None,
tenant_id: str = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Create a new price list item for a supplier"""
try:
service = SupplierService(db)
# Verify supplier exists
supplier = await service.get_supplier(supplier_id)
if not supplier:
raise HTTPException(status_code=404, detail="Supplier not found")
price_list = await service.create_supplier_price_list(
supplier_id=supplier_id,
price_list_data=price_list_data,
tenant_id=UUID(tenant_id),
created_by=UUID(current_user["user_id"])
)
logger.info(
"Created supplier price list item",
supplier_id=str(supplier_id),
price_list_id=str(price_list.id)
)
return SupplierPriceListResponse.from_orm(price_list)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(
"Error creating supplier price list item",
supplier_id=str(supplier_id),
error=str(e)
)
raise HTTPException(
status_code=500,
detail="Failed to create supplier price list item"
)
@router.put(
route_builder.build_resource_action_route("", "supplier_id", "price-lists/{price_list_id}"),
response_model=SupplierPriceListResponse
)
@require_user_role(['admin', 'owner', 'member'])
async def update_supplier_price_list(
supplier_id: UUID = Path(..., description="Supplier ID"),
price_list_id: UUID = Path(..., description="Price List ID"),
price_list_data: SupplierPriceListUpdate = None,
tenant_id: str = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Update a price list item for a supplier"""
try:
service = SupplierService(db)
# Verify supplier and price list exist
supplier = await service.get_supplier(supplier_id)
if not supplier:
raise HTTPException(status_code=404, detail="Supplier not found")
price_list = await service.get_supplier_price_list(
price_list_id=price_list_id,
tenant_id=UUID(tenant_id)
)
if not price_list:
raise HTTPException(status_code=404, detail="Price list item not found")
updated_price_list = await service.update_supplier_price_list(
price_list_id=price_list_id,
price_list_data=price_list_data,
tenant_id=UUID(tenant_id),
updated_by=UUID(current_user["user_id"])
)
logger.info(
"Updated supplier price list item",
supplier_id=str(supplier_id),
price_list_id=str(price_list_id)
)
return SupplierPriceListResponse.from_orm(updated_price_list)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(
"Error updating supplier price list item",
supplier_id=str(supplier_id),
price_list_id=str(price_list_id),
error=str(e)
)
raise HTTPException(
status_code=500,
detail="Failed to update supplier price list item"
)
@router.delete(
route_builder.build_resource_action_route("", "supplier_id", "price-lists/{price_list_id}")
)
@require_user_role(['admin', 'owner'])
async def delete_supplier_price_list(
supplier_id: UUID = Path(..., description="Supplier ID"),
price_list_id: UUID = Path(..., description="Price List ID"),
tenant_id: str = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Delete a price list item for a supplier"""
try:
service = SupplierService(db)
# Verify supplier and price list exist
supplier = await service.get_supplier(supplier_id)
if not supplier:
raise HTTPException(status_code=404, detail="Supplier not found")
price_list = await service.get_supplier_price_list(
price_list_id=price_list_id,
tenant_id=UUID(tenant_id)
)
if not price_list:
raise HTTPException(status_code=404, detail="Price list item not found")
success = await service.delete_supplier_price_list(
price_list_id=price_list_id,
tenant_id=UUID(tenant_id)
)
if not success:
raise HTTPException(status_code=404, detail="Price list item not found")
logger.info(
"Deleted supplier price list item",
supplier_id=str(supplier_id),
price_list_id=str(price_list_id)
)
return {"message": "Price list item deleted successfully"}
except Exception as e:
logger.error(
"Error deleting supplier price list item",
supplier_id=str(supplier_id),
price_list_id=str(price_list_id),
error=str(e)
)
raise HTTPException(
status_code=500,
detail="Failed to delete supplier price list item"
)