- Remove dead kitchen->KDS internal API (api/internal.py, verify_internal_secret) — KDS reads kitchen_db directly (E16), nothing ever called this endpoint - Add expires_at to dispute_attachments; public attachment links now expire after 30 days instead of staying valid forever (A4) - Add services/upload_validation.py: sniff real file content via python-magic instead of trusting the client-supplied Content-Type header, plus a 20MB cap. Applied across invoices/logbook/food_flags/credit_notes/disputes upload endpoints (A5) — disputes previously had no file-type check at all - Fix nginx client_max_body_size drift (800m -> the plan's intended 20m) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
744 lines
22 KiB
Python
744 lines
22 KiB
Python
"""
|
|
Logbook API for wastage, transfers, staff food, and manual adjustments.
|
|
"""
|
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, and_, or_, func
|
|
from sqlalchemy.orm import selectinload
|
|
from datetime import date, datetime
|
|
from typing import Optional, List
|
|
from pydantic import BaseModel
|
|
from decimal import Decimal
|
|
import os
|
|
import logging
|
|
|
|
from auth import get_current_user, require_cap
|
|
from database import get_db
|
|
from services.upload_validation import read_and_validate_upload
|
|
from models.user import User
|
|
from models.logbook import (
|
|
LogbookEntry, LogbookLineItem, LogbookAttachment,
|
|
EntryType, WastageReason, TransferStatus
|
|
)
|
|
# from models.products import Product # TODO: Add Product model
|
|
|
|
router = APIRouter(prefix="/logbook", tags=["Logbook"], dependencies=[Depends(require_cap("logbook"))])
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ============ Pydantic Schemas ============
|
|
|
|
class LineItemInput(BaseModel):
|
|
product_id: Optional[int] = None
|
|
product_name: str
|
|
product_code: Optional[str] = None
|
|
supplier_name: Optional[str] = None
|
|
quantity: float
|
|
unit: Optional[str] = None
|
|
unit_price: Optional[float] = None
|
|
total_cost: float
|
|
notes: Optional[str] = None
|
|
|
|
|
|
class WastageEntryInput(BaseModel):
|
|
entry_date: date
|
|
reason: WastageReason
|
|
line_items: List[LineItemInput]
|
|
notes: Optional[str] = None
|
|
reference_number: Optional[str] = None
|
|
|
|
|
|
class TransferEntryInput(BaseModel):
|
|
entry_date: date
|
|
destination_kitchen_id: int
|
|
status: TransferStatus = TransferStatus.PENDING
|
|
line_items: List[LineItemInput]
|
|
notes: Optional[str] = None
|
|
reference_number: Optional[str] = None
|
|
|
|
|
|
class StaffFoodEntryInput(BaseModel):
|
|
entry_date: date
|
|
meal_type: str # breakfast, lunch, dinner, snack
|
|
staff_count: Optional[int] = None
|
|
line_items: List[LineItemInput]
|
|
notes: Optional[str] = None
|
|
|
|
|
|
class ManualAdjustmentInput(BaseModel):
|
|
entry_date: date
|
|
adjustment_reason: str
|
|
original_invoice_id: Optional[int] = None
|
|
line_items: List[LineItemInput]
|
|
notes: Optional[str] = None
|
|
reference_number: Optional[str] = None
|
|
|
|
|
|
class LineItemResponse(BaseModel):
|
|
id: int
|
|
product_id: Optional[int]
|
|
product_name: str
|
|
product_code: Optional[str]
|
|
supplier_name: Optional[str]
|
|
quantity: float
|
|
unit: Optional[str]
|
|
unit_price: Optional[float]
|
|
total_cost: float
|
|
notes: Optional[str]
|
|
|
|
|
|
class AttachmentResponse(BaseModel):
|
|
id: int
|
|
file_name: str
|
|
file_path: str
|
|
file_type: str
|
|
file_size_bytes: int
|
|
description: Optional[str]
|
|
uploaded_at: str
|
|
|
|
|
|
class LogbookEntryResponse(BaseModel):
|
|
id: int
|
|
entry_type: str
|
|
entry_date: str
|
|
reference_number: Optional[str]
|
|
total_cost: float
|
|
notes: Optional[str]
|
|
type_data: dict
|
|
created_by: int
|
|
created_by_name: Optional[str]
|
|
created_at: str
|
|
line_items: List[LineItemResponse]
|
|
attachments: List[AttachmentResponse]
|
|
|
|
|
|
class LogbookSummary(BaseModel):
|
|
total_entries: int
|
|
total_cost: float
|
|
by_type: dict
|
|
|
|
|
|
# ============ Helper Functions ============
|
|
|
|
def build_entry_response(entry: LogbookEntry) -> LogbookEntryResponse:
|
|
"""Convert LogbookEntry model to response"""
|
|
return LogbookEntryResponse(
|
|
id=entry.id,
|
|
entry_type=entry.entry_type.value,
|
|
entry_date=entry.entry_date.isoformat(),
|
|
reference_number=entry.reference_number,
|
|
total_cost=float(entry.total_cost),
|
|
notes=entry.notes,
|
|
type_data=entry.type_data or {},
|
|
created_by=entry.created_by,
|
|
created_by_name=entry.created_by_user.name if entry.created_by_user else None,
|
|
created_at=entry.created_at.isoformat(),
|
|
line_items=[
|
|
LineItemResponse(
|
|
id=item.id,
|
|
product_id=item.product_id,
|
|
product_name=item.product_name,
|
|
product_code=item.product_code,
|
|
supplier_name=item.supplier_name,
|
|
quantity=float(item.quantity),
|
|
unit=item.unit,
|
|
unit_price=float(item.unit_price) if item.unit_price else None,
|
|
total_cost=float(item.total_cost),
|
|
notes=item.notes
|
|
)
|
|
for item in entry.line_items
|
|
],
|
|
attachments=[
|
|
AttachmentResponse(
|
|
id=att.id,
|
|
file_name=att.file_name,
|
|
file_path=att.file_path,
|
|
file_type=att.file_type,
|
|
file_size_bytes=att.file_size_bytes,
|
|
description=att.description,
|
|
uploaded_at=att.uploaded_at.isoformat()
|
|
)
|
|
for att in entry.attachments
|
|
]
|
|
)
|
|
|
|
|
|
async def create_line_items(
|
|
db: AsyncSession,
|
|
entry_id: int,
|
|
kitchen_id: int,
|
|
items: List[LineItemInput]
|
|
) -> Decimal:
|
|
"""Create line items for an entry and return total cost"""
|
|
total_cost = Decimal(0)
|
|
|
|
for item_input in items:
|
|
line_item = LogbookLineItem(
|
|
entry_id=entry_id,
|
|
kitchen_id=kitchen_id,
|
|
product_id=item_input.product_id,
|
|
product_name=item_input.product_name,
|
|
product_code=item_input.product_code,
|
|
supplier_name=item_input.supplier_name,
|
|
quantity=Decimal(str(item_input.quantity)),
|
|
unit=item_input.unit,
|
|
unit_price=Decimal(str(item_input.unit_price)) if item_input.unit_price else None,
|
|
total_cost=Decimal(str(item_input.total_cost)),
|
|
notes=item_input.notes
|
|
)
|
|
db.add(line_item)
|
|
total_cost += line_item.total_cost
|
|
|
|
return total_cost
|
|
|
|
|
|
# ============ Endpoints ============
|
|
|
|
@router.get("")
|
|
async def get_logbook_entries(
|
|
entry_type: Optional[EntryType] = None,
|
|
date_from: Optional[date] = None,
|
|
date_to: Optional[date] = None,
|
|
search: Optional[str] = None,
|
|
limit: int = 100,
|
|
offset: int = 0,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
) -> List[LogbookEntryResponse]:
|
|
"""Get logbook entries with filters"""
|
|
|
|
query = select(LogbookEntry).options(
|
|
selectinload(LogbookEntry.line_items),
|
|
selectinload(LogbookEntry.attachments),
|
|
selectinload(LogbookEntry.created_by_user)
|
|
).where(
|
|
and_(
|
|
LogbookEntry.kitchen_id == current_user.kitchen_id,
|
|
LogbookEntry.is_deleted == False
|
|
)
|
|
)
|
|
|
|
if entry_type:
|
|
query = query.where(LogbookEntry.entry_type == entry_type)
|
|
|
|
if date_from:
|
|
query = query.where(LogbookEntry.entry_date >= date_from)
|
|
|
|
if date_to:
|
|
query = query.where(LogbookEntry.entry_date <= date_to)
|
|
|
|
if search:
|
|
# Search in notes, reference number, and line item product names
|
|
query = query.outerjoin(LogbookLineItem).where(
|
|
or_(
|
|
LogbookEntry.notes.ilike(f"%{search}%"),
|
|
LogbookEntry.reference_number.ilike(f"%{search}%"),
|
|
LogbookLineItem.product_name.ilike(f"%{search}%")
|
|
)
|
|
).distinct()
|
|
|
|
query = query.order_by(LogbookEntry.entry_date.desc(), LogbookEntry.created_at.desc())
|
|
query = query.limit(limit).offset(offset)
|
|
|
|
try:
|
|
result = await db.execute(query)
|
|
entries = result.scalars().unique().all()
|
|
return [build_entry_response(entry) for entry in entries]
|
|
except Exception as e:
|
|
logger.exception(f"Error fetching logbook entries: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Error fetching entries: {str(e)}")
|
|
|
|
|
|
@router.get("/summary")
|
|
async def get_logbook_summary(
|
|
date_from: Optional[date] = None,
|
|
date_to: Optional[date] = None,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
) -> LogbookSummary:
|
|
"""Get summary statistics for logbook entries"""
|
|
|
|
query = select(
|
|
LogbookEntry.entry_type,
|
|
func.count(LogbookEntry.id).label('count'),
|
|
func.sum(LogbookEntry.total_cost).label('total')
|
|
).where(
|
|
and_(
|
|
LogbookEntry.kitchen_id == current_user.kitchen_id,
|
|
LogbookEntry.is_deleted == False
|
|
)
|
|
).group_by(LogbookEntry.entry_type)
|
|
|
|
if date_from:
|
|
query = query.where(LogbookEntry.entry_date >= date_from)
|
|
|
|
if date_to:
|
|
query = query.where(LogbookEntry.entry_date <= date_to)
|
|
|
|
result = await db.execute(query)
|
|
rows = result.all()
|
|
|
|
by_type = {}
|
|
total_entries = 0
|
|
total_cost = Decimal(0)
|
|
|
|
for row in rows:
|
|
entry_type, count, cost = row
|
|
by_type[entry_type.value] = {
|
|
'count': count,
|
|
'total_cost': float(cost or 0)
|
|
}
|
|
total_entries += count
|
|
total_cost += cost or 0
|
|
|
|
return LogbookSummary(
|
|
total_entries=total_entries,
|
|
total_cost=float(total_cost),
|
|
by_type=by_type
|
|
)
|
|
|
|
|
|
@router.get("/daily-stats")
|
|
async def get_daily_logbook_stats(
|
|
date_from: Optional[date] = None,
|
|
date_to: Optional[date] = None,
|
|
entry_type: Optional[EntryType] = None,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Get daily logbook statistics for a date range (for purchases chart integration)"""
|
|
|
|
query = select(
|
|
LogbookEntry.entry_date,
|
|
LogbookEntry.entry_type,
|
|
func.count(LogbookEntry.id).label("count"),
|
|
func.sum(LogbookEntry.total_cost).label("total_cost")
|
|
).where(
|
|
and_(
|
|
LogbookEntry.kitchen_id == current_user.kitchen_id,
|
|
LogbookEntry.is_deleted == False
|
|
)
|
|
)
|
|
|
|
if date_from:
|
|
query = query.where(LogbookEntry.entry_date >= date_from)
|
|
if date_to:
|
|
query = query.where(LogbookEntry.entry_date <= date_to)
|
|
if entry_type:
|
|
query = query.where(LogbookEntry.entry_type == entry_type)
|
|
|
|
query = query.group_by(LogbookEntry.entry_date, LogbookEntry.entry_type)
|
|
query = query.order_by(LogbookEntry.entry_date)
|
|
|
|
result = await db.execute(query)
|
|
rows = result.all()
|
|
|
|
# Group by date, then by type
|
|
daily_stats = {}
|
|
for row in rows:
|
|
date_str = row.entry_date.isoformat()
|
|
if date_str not in daily_stats:
|
|
daily_stats[date_str] = {}
|
|
daily_stats[date_str][row.entry_type.value] = {
|
|
"count": row.count,
|
|
"total_cost": float(row.total_cost or 0)
|
|
}
|
|
|
|
return {"daily_stats": daily_stats}
|
|
|
|
|
|
@router.get("/products/search")
|
|
async def search_products(
|
|
query: str,
|
|
limit: int = 20,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Search products from invoice line items for logbook entry"""
|
|
from models.line_item import LineItem
|
|
from models.invoice import Invoice
|
|
from models.supplier import Supplier
|
|
|
|
# Search line items from invoices for this kitchen
|
|
# Get distinct products with most recent price
|
|
result = await db.execute(
|
|
select(
|
|
LineItem.description,
|
|
LineItem.product_code,
|
|
LineItem.unit,
|
|
LineItem.unit_price,
|
|
Supplier.name.label('supplier_name')
|
|
)
|
|
.join(Invoice, LineItem.invoice_id == Invoice.id)
|
|
.outerjoin(Supplier, Invoice.supplier_id == Supplier.id)
|
|
.where(
|
|
and_(
|
|
Invoice.kitchen_id == current_user.kitchen_id,
|
|
or_(
|
|
LineItem.description.ilike(f"%{query}%"),
|
|
LineItem.product_code.ilike(f"%{query}%")
|
|
)
|
|
)
|
|
)
|
|
.order_by(Invoice.invoice_date.desc())
|
|
.limit(limit * 3) # Get more to allow for deduplication
|
|
)
|
|
rows = result.all()
|
|
|
|
# Deduplicate by description, keeping first (most recent) price
|
|
seen = set()
|
|
products = []
|
|
for row in rows:
|
|
key = (row.description or '').lower()
|
|
if key and key not in seen:
|
|
seen.add(key)
|
|
products.append({
|
|
"id": 0, # No persistent product ID
|
|
"name": row.description,
|
|
"product_code": row.product_code,
|
|
"supplier_name": row.supplier_name,
|
|
"unit": row.unit,
|
|
"last_price": float(row.unit_price) if row.unit_price else None
|
|
})
|
|
if len(products) >= limit:
|
|
break
|
|
|
|
return products
|
|
|
|
|
|
@router.get("/{entry_id}")
|
|
async def get_logbook_entry(
|
|
entry_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
) -> LogbookEntryResponse:
|
|
"""Get single logbook entry with details"""
|
|
|
|
result = await db.execute(
|
|
select(LogbookEntry).where(
|
|
and_(
|
|
LogbookEntry.id == entry_id,
|
|
LogbookEntry.kitchen_id == current_user.kitchen_id,
|
|
LogbookEntry.is_deleted == False
|
|
)
|
|
)
|
|
)
|
|
entry = result.scalar_one_or_none()
|
|
|
|
if not entry:
|
|
raise HTTPException(status_code=404, detail="Entry not found")
|
|
|
|
return build_entry_response(entry)
|
|
|
|
|
|
@router.post("/wastage")
|
|
async def create_wastage_entry(
|
|
entry_input: WastageEntryInput,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
) -> LogbookEntryResponse:
|
|
"""Create wastage entry"""
|
|
|
|
if not entry_input.line_items:
|
|
raise HTTPException(status_code=400, detail="At least one line item is required")
|
|
|
|
entry = LogbookEntry(
|
|
kitchen_id=current_user.kitchen_id,
|
|
entry_type=EntryType.WASTAGE,
|
|
entry_date=entry_input.entry_date,
|
|
reference_number=entry_input.reference_number,
|
|
notes=entry_input.notes,
|
|
type_data={"reason": entry_input.reason.value},
|
|
created_by=current_user.id
|
|
)
|
|
|
|
db.add(entry)
|
|
await db.flush() # Get entry.id
|
|
|
|
total_cost = await create_line_items(db, entry.id, current_user.kitchen_id, entry_input.line_items)
|
|
entry.total_cost = total_cost
|
|
|
|
await db.commit()
|
|
await db.refresh(entry)
|
|
|
|
logger.info(f"Created wastage entry {entry.id} for kitchen {current_user.kitchen_id}, cost: {total_cost}")
|
|
|
|
return await get_logbook_entry(entry.id, current_user, db)
|
|
|
|
|
|
@router.post("/transfer")
|
|
async def create_transfer_entry(
|
|
entry_input: TransferEntryInput,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
) -> LogbookEntryResponse:
|
|
"""Create transfer entry"""
|
|
|
|
if not entry_input.line_items:
|
|
raise HTTPException(status_code=400, detail="At least one line item is required")
|
|
|
|
entry = LogbookEntry(
|
|
kitchen_id=current_user.kitchen_id,
|
|
entry_type=EntryType.TRANSFER,
|
|
entry_date=entry_input.entry_date,
|
|
reference_number=entry_input.reference_number,
|
|
notes=entry_input.notes,
|
|
type_data={
|
|
"destination_kitchen_id": entry_input.destination_kitchen_id,
|
|
"status": entry_input.status.value
|
|
},
|
|
created_by=current_user.id
|
|
)
|
|
|
|
db.add(entry)
|
|
await db.flush()
|
|
|
|
total_cost = await create_line_items(db, entry.id, current_user.kitchen_id, entry_input.line_items)
|
|
entry.total_cost = total_cost
|
|
|
|
await db.commit()
|
|
await db.refresh(entry)
|
|
|
|
logger.info(f"Created transfer entry {entry.id} for kitchen {current_user.kitchen_id} -> {entry_input.destination_kitchen_id}")
|
|
|
|
# TODO: Send notification to destination kitchen
|
|
|
|
return await get_logbook_entry(entry.id, current_user, db)
|
|
|
|
|
|
@router.post("/staff-food")
|
|
async def create_staff_food_entry(
|
|
entry_input: StaffFoodEntryInput,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
) -> LogbookEntryResponse:
|
|
"""Create staff food entry"""
|
|
|
|
if not entry_input.line_items:
|
|
raise HTTPException(status_code=400, detail="At least one line item is required")
|
|
|
|
entry = LogbookEntry(
|
|
kitchen_id=current_user.kitchen_id,
|
|
entry_type=EntryType.STAFF_FOOD,
|
|
entry_date=entry_input.entry_date,
|
|
notes=entry_input.notes,
|
|
type_data={
|
|
"meal_type": entry_input.meal_type,
|
|
"staff_count": entry_input.staff_count
|
|
},
|
|
created_by=current_user.id
|
|
)
|
|
|
|
db.add(entry)
|
|
await db.flush()
|
|
|
|
total_cost = await create_line_items(db, entry.id, current_user.kitchen_id, entry_input.line_items)
|
|
entry.total_cost = total_cost
|
|
|
|
await db.commit()
|
|
await db.refresh(entry)
|
|
|
|
logger.info(f"Created staff food entry {entry.id} for kitchen {current_user.kitchen_id}, meal: {entry_input.meal_type}")
|
|
|
|
return await get_logbook_entry(entry.id, current_user, db)
|
|
|
|
|
|
@router.post("/manual-adjustment")
|
|
async def create_manual_adjustment_entry(
|
|
entry_input: ManualAdjustmentInput,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
) -> LogbookEntryResponse:
|
|
"""Create manual adjustment entry"""
|
|
|
|
if not entry_input.line_items:
|
|
raise HTTPException(status_code=400, detail="At least one line item is required")
|
|
|
|
entry = LogbookEntry(
|
|
kitchen_id=current_user.kitchen_id,
|
|
entry_type=EntryType.MANUAL_ADJUSTMENT,
|
|
entry_date=entry_input.entry_date,
|
|
reference_number=entry_input.reference_number,
|
|
notes=entry_input.notes,
|
|
type_data={
|
|
"adjustment_reason": entry_input.adjustment_reason,
|
|
"original_invoice_id": entry_input.original_invoice_id
|
|
},
|
|
created_by=current_user.id
|
|
)
|
|
|
|
db.add(entry)
|
|
await db.flush()
|
|
|
|
total_cost = await create_line_items(db, entry.id, current_user.kitchen_id, entry_input.line_items)
|
|
entry.total_cost = total_cost
|
|
|
|
await db.commit()
|
|
await db.refresh(entry)
|
|
|
|
logger.info(f"Created manual adjustment entry {entry.id} for kitchen {current_user.kitchen_id}")
|
|
|
|
return await get_logbook_entry(entry.id, current_user, db)
|
|
|
|
|
|
@router.patch("/{entry_id}")
|
|
async def update_logbook_entry(
|
|
entry_id: int,
|
|
notes: Optional[str] = None,
|
|
reference_number: Optional[str] = None,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Update logbook entry (limited fields)"""
|
|
|
|
result = await db.execute(
|
|
select(LogbookEntry).where(
|
|
and_(
|
|
LogbookEntry.id == entry_id,
|
|
LogbookEntry.kitchen_id == current_user.kitchen_id,
|
|
LogbookEntry.is_deleted == False
|
|
)
|
|
)
|
|
)
|
|
entry = result.scalar_one_or_none()
|
|
|
|
if not entry:
|
|
raise HTTPException(status_code=404, detail="Entry not found")
|
|
|
|
if notes is not None:
|
|
entry.notes = notes
|
|
|
|
if reference_number is not None:
|
|
entry.reference_number = reference_number
|
|
|
|
entry.updated_at = datetime.utcnow()
|
|
await db.commit()
|
|
|
|
return {"status": "updated"}
|
|
|
|
|
|
@router.delete("/{entry_id}")
|
|
async def delete_logbook_entry(
|
|
entry_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Soft delete logbook entry"""
|
|
|
|
result = await db.execute(
|
|
select(LogbookEntry).where(
|
|
and_(
|
|
LogbookEntry.id == entry_id,
|
|
LogbookEntry.kitchen_id == current_user.kitchen_id
|
|
)
|
|
)
|
|
)
|
|
entry = result.scalar_one_or_none()
|
|
|
|
if not entry:
|
|
raise HTTPException(status_code=404, detail="Entry not found")
|
|
|
|
entry.is_deleted = True
|
|
entry.updated_at = datetime.utcnow()
|
|
await db.commit()
|
|
|
|
logger.info(f"Deleted logbook entry {entry_id} for kitchen {current_user.kitchen_id}")
|
|
|
|
return {"status": "deleted"}
|
|
|
|
|
|
@router.post("/{entry_id}/attachments")
|
|
async def upload_attachment(
|
|
entry_id: int,
|
|
file: UploadFile = File(...),
|
|
description: Optional[str] = Form(None),
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Upload photo or document to logbook entry"""
|
|
|
|
# Verify entry exists and belongs to user's kitchen
|
|
result = await db.execute(
|
|
select(LogbookEntry).where(
|
|
and_(
|
|
LogbookEntry.id == entry_id,
|
|
LogbookEntry.kitchen_id == current_user.kitchen_id,
|
|
LogbookEntry.is_deleted == False
|
|
)
|
|
)
|
|
)
|
|
entry = result.scalar_one_or_none()
|
|
if not entry:
|
|
raise HTTPException(status_code=404, detail="Entry not found")
|
|
|
|
# Validate file type (sniffed from content, not the client header — A5)
|
|
allowed_types = {"image/jpeg", "image/png", "image/heic", "image/webp", "application/pdf"}
|
|
content = await read_and_validate_upload(file, allowed_types)
|
|
|
|
# Save file
|
|
upload_dir = f"/app/attachments/logbook/kitchen_{current_user.kitchen_id}"
|
|
os.makedirs(upload_dir, exist_ok=True)
|
|
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
file_extension = os.path.splitext(file.filename)[1] if file.filename else ".jpg"
|
|
file_name = f"entry_{entry_id}_{timestamp}{file_extension}"
|
|
file_path = f"{upload_dir}/{file_name}"
|
|
|
|
with open(file_path, "wb") as f:
|
|
f.write(content)
|
|
|
|
# Create attachment record
|
|
attachment = LogbookAttachment(
|
|
entry_id=entry_id,
|
|
kitchen_id=current_user.kitchen_id,
|
|
file_name=file.filename or file_name,
|
|
file_path=file_path,
|
|
file_type=file.content_type,
|
|
file_size_bytes=len(content),
|
|
description=description,
|
|
uploaded_by=current_user.id
|
|
)
|
|
|
|
db.add(attachment)
|
|
await db.commit()
|
|
|
|
logger.info(f"Uploaded attachment {attachment.id} to entry {entry_id}")
|
|
|
|
return {
|
|
"id": attachment.id,
|
|
"file_name": attachment.file_name,
|
|
"file_path": attachment.file_path
|
|
}
|
|
|
|
|
|
@router.delete("/{entry_id}/attachments/{attachment_id}")
|
|
async def delete_attachment(
|
|
entry_id: int,
|
|
attachment_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Delete attachment from logbook entry"""
|
|
|
|
result = await db.execute(
|
|
select(LogbookAttachment).where(
|
|
and_(
|
|
LogbookAttachment.id == attachment_id,
|
|
LogbookAttachment.entry_id == entry_id,
|
|
LogbookAttachment.kitchen_id == current_user.kitchen_id
|
|
)
|
|
)
|
|
)
|
|
attachment = result.scalar_one_or_none()
|
|
|
|
if not attachment:
|
|
raise HTTPException(status_code=404, detail="Attachment not found")
|
|
|
|
# Delete file from disk
|
|
if os.path.exists(attachment.file_path):
|
|
os.remove(attachment.file_path)
|
|
|
|
await db.delete(attachment)
|
|
await db.commit()
|
|
|
|
return {"status": "deleted"}
|