302 lines
10 KiB
Python
302 lines
10 KiB
Python
# services/inventory/app/api/food_safety_compliance.py
|
|
"""
|
|
Food Safety Compliance API - ATOMIC CRUD operations on FoodSafetyCompliance model
|
|
"""
|
|
|
|
from typing import List, Optional
|
|
from uuid import UUID
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
import structlog
|
|
|
|
from shared.auth.decorators import get_current_user_dep
|
|
from shared.auth.access_control import require_user_role
|
|
from shared.routing import RouteBuilder
|
|
from app.core.database import get_db
|
|
from app.services.food_safety_service import FoodSafetyService
|
|
from app.schemas.food_safety import (
|
|
FoodSafetyComplianceCreate,
|
|
FoodSafetyComplianceUpdate,
|
|
FoodSafetyComplianceResponse
|
|
)
|
|
|
|
logger = structlog.get_logger()
|
|
route_builder = RouteBuilder('inventory')
|
|
router = APIRouter(tags=["food-safety-compliance"])
|
|
|
|
|
|
async def get_food_safety_service() -> FoodSafetyService:
|
|
"""Get food safety service instance"""
|
|
return FoodSafetyService()
|
|
|
|
|
|
@router.post(
|
|
route_builder.build_base_route("food-safety/compliance"),
|
|
response_model=FoodSafetyComplianceResponse,
|
|
status_code=status.HTTP_201_CREATED
|
|
)
|
|
@require_user_role(['admin', 'owner', 'member'])
|
|
async def create_compliance_record(
|
|
compliance_data: FoodSafetyComplianceCreate,
|
|
tenant_id: UUID = Path(...),
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
food_safety_service: FoodSafetyService = Depends(get_food_safety_service),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Create a new food safety compliance record"""
|
|
try:
|
|
compliance_data.tenant_id = tenant_id
|
|
|
|
compliance = await food_safety_service.create_compliance_record(
|
|
db,
|
|
compliance_data,
|
|
user_id=UUID(current_user["sub"])
|
|
)
|
|
|
|
logger.info("Compliance record created",
|
|
compliance_id=str(compliance.id),
|
|
standard=compliance.standard)
|
|
|
|
return compliance
|
|
|
|
except ValueError as e:
|
|
logger.warning("Invalid compliance data", error=str(e))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=str(e)
|
|
)
|
|
except Exception as e:
|
|
logger.error("Error creating compliance record", error=str(e))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to create compliance record"
|
|
)
|
|
|
|
|
|
@router.get(
|
|
route_builder.build_base_route("food-safety/compliance"),
|
|
response_model=List[FoodSafetyComplianceResponse]
|
|
)
|
|
async def get_compliance_records(
|
|
tenant_id: UUID = Path(...),
|
|
ingredient_id: Optional[UUID] = Query(None, description="Filter by ingredient ID"),
|
|
standard: Optional[str] = Query(None, description="Filter by compliance standard"),
|
|
status_filter: Optional[str] = Query(None, description="Filter by compliance status"),
|
|
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
|
limit: int = Query(100, ge=1, le=1000, description="Number of records to return"),
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Get compliance records with filtering"""
|
|
try:
|
|
filters = {}
|
|
if ingredient_id:
|
|
filters["ingredient_id"] = ingredient_id
|
|
if standard:
|
|
filters["standard"] = standard
|
|
if status_filter:
|
|
filters["compliance_status"] = status_filter
|
|
|
|
query = """
|
|
SELECT * FROM food_safety_compliance
|
|
WHERE tenant_id = :tenant_id AND is_active = true
|
|
"""
|
|
params = {"tenant_id": tenant_id}
|
|
|
|
if filters:
|
|
for key, value in filters.items():
|
|
query += f" AND {key} = :{key}"
|
|
params[key] = value
|
|
|
|
query += " ORDER BY created_at DESC LIMIT :limit OFFSET :skip"
|
|
params.update({"limit": limit, "skip": skip})
|
|
|
|
result = await db.execute(query, params)
|
|
records = result.fetchall()
|
|
|
|
return [
|
|
FoodSafetyComplianceResponse(**dict(record))
|
|
for record in records
|
|
]
|
|
|
|
except Exception as e:
|
|
logger.error("Error getting compliance records", error=str(e))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to retrieve compliance records"
|
|
)
|
|
|
|
|
|
@router.get(
|
|
route_builder.build_resource_detail_route("food-safety/compliance", "compliance_id"),
|
|
response_model=FoodSafetyComplianceResponse
|
|
)
|
|
async def get_compliance_record(
|
|
compliance_id: UUID = Path(...),
|
|
tenant_id: UUID = Path(...),
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Get specific compliance record"""
|
|
try:
|
|
query = "SELECT * FROM food_safety_compliance WHERE id = :compliance_id AND tenant_id = :tenant_id"
|
|
result = await db.execute(query, {"compliance_id": compliance_id, "tenant_id": tenant_id})
|
|
record = result.fetchone()
|
|
|
|
if not record:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Compliance record not found"
|
|
)
|
|
|
|
return FoodSafetyComplianceResponse(**dict(record))
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Error getting compliance record", error=str(e))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to retrieve compliance record"
|
|
)
|
|
|
|
|
|
@router.put(
|
|
route_builder.build_resource_detail_route("food-safety/compliance", "compliance_id"),
|
|
response_model=FoodSafetyComplianceResponse
|
|
)
|
|
@require_user_role(['admin', 'owner', 'member'])
|
|
async def update_compliance_record(
|
|
compliance_data: FoodSafetyComplianceUpdate,
|
|
tenant_id: UUID = Path(...),
|
|
compliance_id: UUID = Path(...),
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
food_safety_service: FoodSafetyService = Depends(get_food_safety_service),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Update an existing compliance record"""
|
|
try:
|
|
compliance = await food_safety_service.update_compliance_record(
|
|
db,
|
|
compliance_id,
|
|
tenant_id,
|
|
compliance_data,
|
|
user_id=UUID(current_user["sub"])
|
|
)
|
|
|
|
if not compliance:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Compliance record not found"
|
|
)
|
|
|
|
logger.info("Compliance record updated",
|
|
compliance_id=str(compliance.id))
|
|
|
|
return compliance
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Error updating compliance record",
|
|
compliance_id=str(compliance_id),
|
|
error=str(e))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to update compliance record"
|
|
)
|
|
|
|
|
|
@router.delete(
|
|
route_builder.build_resource_detail_route("food-safety/compliance", "compliance_id"),
|
|
status_code=status.HTTP_403_FORBIDDEN
|
|
)
|
|
@require_user_role(['admin', 'owner'])
|
|
async def delete_compliance_record(
|
|
compliance_id: UUID = Path(...),
|
|
tenant_id: UUID = Path(...),
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Compliance records CANNOT be deleted for regulatory compliance.
|
|
Use the archive endpoint to mark records as inactive.
|
|
"""
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail={
|
|
"error": "compliance_records_cannot_be_deleted",
|
|
"message": "Compliance records cannot be deleted for regulatory compliance. Use PUT /food-safety/compliance/{id}/archive to archive records instead.",
|
|
"reason": "Food safety compliance records must be retained for regulatory audits",
|
|
"alternative_endpoint": f"/api/v1/tenants/{tenant_id}/inventory/food-safety/compliance/{compliance_id}/archive"
|
|
}
|
|
)
|
|
|
|
|
|
@router.put(
|
|
route_builder.build_nested_resource_route("food-safety/compliance", "compliance_id", "archive"),
|
|
response_model=dict
|
|
)
|
|
@require_user_role(['admin', 'owner'])
|
|
async def archive_compliance_record(
|
|
compliance_id: UUID = Path(...),
|
|
tenant_id: UUID = Path(...),
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Archive (soft delete) compliance record - marks as inactive but retains for audit"""
|
|
try:
|
|
query = """
|
|
UPDATE food_safety_compliance
|
|
SET is_active = false, updated_at = NOW(), updated_by = :user_id
|
|
WHERE id = :compliance_id AND tenant_id = :tenant_id
|
|
"""
|
|
result = await db.execute(query, {
|
|
"compliance_id": compliance_id,
|
|
"tenant_id": tenant_id,
|
|
"user_id": UUID(current_user["user_id"])
|
|
})
|
|
|
|
if result.rowcount == 0:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Compliance record not found"
|
|
)
|
|
|
|
await db.commit()
|
|
|
|
# Log audit event for archiving compliance record
|
|
try:
|
|
from shared.security import create_audit_logger, AuditSeverity, AuditAction
|
|
audit_logger = create_audit_logger("inventory-service")
|
|
await audit_logger.log_event(
|
|
db_session=db,
|
|
tenant_id=str(tenant_id),
|
|
user_id=current_user["user_id"],
|
|
action="archive",
|
|
resource_type="compliance_record",
|
|
resource_id=str(compliance_id),
|
|
severity=AuditSeverity.HIGH.value,
|
|
description=f"Archived compliance record (retained for regulatory compliance)",
|
|
endpoint=f"/food-safety/compliance/{compliance_id}/archive",
|
|
method="PUT"
|
|
)
|
|
except Exception as audit_error:
|
|
logger.warning("Failed to log audit event", error=str(audit_error))
|
|
|
|
return {
|
|
"message": "Compliance record archived successfully",
|
|
"compliance_id": str(compliance_id),
|
|
"archived": True,
|
|
"note": "Record retained for regulatory compliance audits"
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Error archiving compliance record", error=str(e))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to archive compliance record"
|
|
)
|