Initial kitchen scaffold — Phase 1 kitchen port (build-verified 2026-07-11)
FastAPI backend (Python 3.11, MSSQL ODBC for SambaPOS, Azure DI OCR),
kitchen_db on central PG. React/TS/Vite frontend with navy sidebar layout.
Backend: auth.py (APP_SLUG=kitchen, SimpleNamespace — archive routes use
.kitchen_id/.is_admin without modification), main.py (51 migrations, scheduler,
internal router for KDS bookings feed), api/internal.py, full archive API
(31 routers: invoices, recipes, menus, sambapos, resos, newbook, disputes,
purchase_orders, etc.), models, migrations, OCR pipeline.
kitchen_id pinned to 1 (B1 — single hotel).
Frontend: AuthGate (app=kitchen, token shim for archive compat — B5b pending),
Layout (navy sidebar, 6 sections, Lucide icons, teal --app-primary),
App.tsx (Outlet pattern, UploadApp outside Layout), index.css (full :root block).
strict: false — archive components have type issues; build clean.
Note: 45 archive components call fetch('/api/...') without /kitchen/ prefix
(B5b). Runtime 404s; deferred until after initial testing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
8d688b459d
10003 changed files with 1928395 additions and 0 deletions
745
backend/api/logbook.py
Normal file
745
backend/api/logbook.py
Normal file
|
|
@ -0,0 +1,745 @@
|
|||
"""
|
||||
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 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"])
|
||||
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
|
||||
allowed_types = ["image/jpeg", "image/png", "image/heic", "image/webp", "application/pdf"]
|
||||
if file.content_type not in allowed_types:
|
||||
raise HTTPException(status_code=400, detail=f"File type {file.content_type} not allowed. Allowed: {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}"
|
||||
|
||||
content = await file.read()
|
||||
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"}
|
||||
Loading…
Add table
Add a link
Reference in a new issue