Audit pass: cookie auth migration, route guards, GP% clamp, CSV export, OCR transaction safety, N+1 fix, and cleanup

- Migrated all 435 frontend fetch calls from Authorization Bearer header to credentials: 'include' (cookie auth)
- Removed ?token= from all file/image URLs (browser history exposure)
- Added ProtectedRoute wrapper to all capability-gated routes in App.tsx
- OCR background task: added transaction boundary, improved error handling and status rollback
- DuplicateDetector: wrapped in non-fatal try/except so crashes don't abort invoice processing
- File upload: commit DB row before writing to disk to prevent orphaned files
- GP% clamped to 100% in GPReport (credit notes can inflate above 100%)
- Added CSV export to GPReport (suppliers, daily data, allowances breakdown)
- Backend file-serving endpoints: cookie auth with ?token= fallback for backward compatibility
- DATA_DIR: moved from hardcoded /app/data to os.getenv in invoices.py and recipes.py
- N+1 fix in list_recipes: batch-loads latest cost snapshot in 1 query (was N)
- Zero-yield sub-recipe: logs warning instead of silently zeroing cost contribution
- Budget spend rate input: rejects negative values
- GPReport allowances toggle: persisted to localStorage across page loads
- DB pool_size/max_overflow: configurable via DB_POOL_SIZE/DB_MAX_OVERFLOW env vars
- Fixed SyntaxWarning from \\d in invoices.py docstring

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-13 10:04:20 +00:00
parent 6f6e16c88f
commit ba075276b1
57 changed files with 15427 additions and 15274 deletions

View file

@ -5,7 +5,7 @@ import os
import tempfile
import shutil
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, UploadFile, File
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, UploadFile, File, Query, Request
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
@ -282,15 +282,21 @@ async def delete_backup(
@router.get("/{backup_id}/download")
async def download_backup(
backup_id: int,
token: str,
request: Request,
token: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db)
):
"""Download a backup file. Auth via token query param for direct browser downloads."""
from auth import get_current_user, require_cap_from_token
current_user = await get_current_user_from_token(token, db)
"""Download a backup file. Cookie auth preferred; ?token= accepted as fallback."""
current_user = None
if token:
current_user = await get_current_user_from_token(token, db)
if not current_user:
raise HTTPException(status_code=401, detail="Invalid token")
try:
current_user = await get_current_user(request)
except HTTPException:
pass
if not current_user:
raise HTTPException(status_code=401, detail="Not authenticated")
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin only")

View file

@ -6,7 +6,7 @@ from datetime import date, datetime
from decimal import Decimal
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, text, and_, or_, delete, update
@ -783,14 +783,20 @@ async def get_ingredient(
@router.get("/{ingredient_id}/label-image")
async def get_label_image(
ingredient_id: int,
token: str,
request: Request,
token: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db),
):
"""Serve the stored label image for a prepackaged ingredient.
Uses token query param for auth (allows window.open / img src usage)."""
"""Serve the stored label image for a prepackaged ingredient. Cookie auth preferred; ?token= accepted as fallback."""
import os
from auth import get_current_user, require_cap_from_token
user = await get_current_user_from_token(token, db)
user = None
if token:
user = await get_current_user_from_token(token, db)
if not user:
try:
user = await get_current_user(request)
except HTTPException:
pass
if not user:
raise HTTPException(401, "Not authenticated")
result = await db.execute(

View file

@ -118,7 +118,7 @@ def get_line_item_page_numbers_by_line_number(invoice: Invoice) -> dict[int, int
return {}
DATA_DIR = "/app/data"
DATA_DIR = os.getenv("DATA_DIR", "/app/data")
# Response Models
@ -549,7 +549,11 @@ async def upload_invoice(
status=InvoiceStatus.PENDING
)
db.add(invoice)
await db.commit()
try:
await db.commit()
except Exception:
os.remove(filepath)
raise
await db.refresh(invoice)
background_tasks.add_task(
@ -794,19 +798,22 @@ async def process_invoice_background(invoice_id: int, image_path: str, kitchen_i
except Exception as e:
logger.warning(f"Ingredient price auto-update failed (non-critical): {e}")
# Run duplicate detection
detector = DuplicateDetector(db, kitchen_id)
duplicates = await detector.check_duplicates(invoice)
# Run duplicate detection (non-critical — log and continue on failure)
try:
detector = DuplicateDetector(db, kitchen_id)
duplicates = await detector.check_duplicates(invoice)
if duplicates["firm_duplicate"]:
invoice.duplicate_status = "firm_duplicate"
invoice.duplicate_of_id = duplicates["firm_duplicate"].id
elif duplicates["possible_duplicates"]:
invoice.duplicate_status = "possible_duplicate"
invoice.duplicate_of_id = duplicates["possible_duplicates"][0].id
if duplicates["firm_duplicate"]:
invoice.duplicate_status = "firm_duplicate"
invoice.duplicate_of_id = duplicates["firm_duplicate"].id
elif duplicates["possible_duplicates"]:
invoice.duplicate_status = "possible_duplicate"
invoice.duplicate_of_id = duplicates["possible_duplicates"][0].id
if duplicates["related_documents"]:
invoice.related_document_id = duplicates["related_documents"][0].id
if duplicates["related_documents"]:
invoice.related_document_id = duplicates["related_documents"][0].id
except Exception as e:
logger.warning(f"Duplicate detection failed for invoice {invoice_id} (non-critical): {e}")
invoice.status = InvoiceStatus.PROCESSED
await db.commit()
@ -815,13 +822,17 @@ async def process_invoice_background(invoice_id: int, image_path: str, kitchen_i
f"duplicate_status={invoice.duplicate_status}")
except Exception as e:
logger.error(f"OCR processing error for invoice {invoice_id}: {e}")
stmt = select(Invoice).where(Invoice.id == invoice_id)
db_result = await db.execute(stmt)
invoice = db_result.scalar_one()
invoice.status = InvoiceStatus.PROCESSED
invoice.ocr_raw_text = f"Error: {str(e)}"
await db.commit()
logger.error(f"OCR processing error for invoice {invoice_id}: {e}", exc_info=True)
try:
await db.rollback()
stmt = select(Invoice).where(Invoice.id == invoice_id)
db_result = await db.execute(stmt)
invoice = db_result.scalar_one()
invoice.status = InvoiceStatus.PROCESSED
invoice.ocr_raw_text = f"Error: {str(e)}"
await db.commit()
except Exception as update_err:
logger.error(f"Failed to update invoice {invoice_id} status after OCR error: {update_err}")
@router.get("/", response_model=InvoiceListResponse)
@ -2106,19 +2117,26 @@ async def get_invoice_ocr_data(
async def get_line_item_preview(
invoice_id: int,
line_number: int,
token: str,
request: Request,
token: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db)
):
"""Get a cropped image preview of a specific line item from the invoice OCR bounding box."""
import json as json_module
import io
from auth import get_current_user, require_cap_from_token
from starlette.responses import Response
from services.file_archival_service import FileArchivalService
current_user = await get_current_user_from_token(token, db)
current_user = None
if token:
current_user = await get_current_user_from_token(token, db)
if not current_user:
raise HTTPException(status_code=401, detail="Invalid token")
try:
current_user = await get_current_user(request)
except HTTPException:
pass
if not current_user:
raise HTTPException(status_code=401, detail="Not authenticated")
invoice = await get_invoice_or_404(invoice_id, current_user, db)
@ -2220,13 +2238,13 @@ async def get_line_item_field_preview(
invoice_id: int,
line_number: int,
field_name: str,
token: str,
request: Request,
token: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db),
):
"""Get a cropped image preview of a specific field within a line item (e.g. product_code)."""
import json as json_module
import io
from auth import get_current_user, require_cap_from_token
from starlette.responses import Response
from services.file_archival_service import FileArchivalService
@ -2234,9 +2252,16 @@ async def get_line_item_field_preview(
if not azure_key:
raise HTTPException(status_code=400, detail=f"Unknown field: {field_name}")
current_user = await get_current_user_from_token(token, db)
current_user = None
if token:
current_user = await get_current_user_from_token(token, db)
if not current_user:
raise HTTPException(status_code=401, detail="Invalid token")
try:
current_user = await get_current_user(request)
except HTTPException:
pass
if not current_user:
raise HTTPException(status_code=401, detail="Not authenticated")
invoice = await get_invoice_or_404(invoice_id, current_user, db)
@ -2387,11 +2412,11 @@ async def parse_dates_from_ocr(
def _generalize_invoice_number_pattern(sample: str) -> str:
"""
r"""
Convert a known invoice number into a regex that matches similar-shaped numbers.
Uses tight ±1 range on digit runs to avoid matching phone/VAT/postcode numbers.
e.g. 'ID304574' r'\bID\d{5,7}\b'
'INV-00123' r'\bINV-\d{4,6}\b'
e.g. 'ID304574' -> r'\bID\d{5,7}\b'
'INV-00123' -> r'\bINV-\d{4,6}\b'
"""
parts = []
i = 0

View file

@ -10,7 +10,7 @@ from decimal import Decimal
from typing import Optional
from html import escape as html_escape
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, or_, func, delete
@ -661,13 +661,19 @@ def _build_po_html(po: PurchaseOrder, kitchen: KitchenSettings, currency: str =
@router.get("/{po_id}/preview")
async def preview_purchase_order(
po_id: int,
token: Optional[str] = None,
request: Request,
token: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db),
):
"""Return a print-friendly HTML preview of the purchase order (query-param auth)."""
if not token:
raise HTTPException(status_code=401, detail="Token required — use ?token=your_jwt_token")
current_user = await get_current_user_from_token(token, db)
"""Return a print-friendly HTML preview of the purchase order. Cookie auth preferred; ?token= accepted as fallback."""
current_user = None
if token:
current_user = await get_current_user_from_token(token, db)
if not current_user:
try:
current_user = await get_current_user(request)
except HTTPException:
pass
if not current_user:
raise HTTPException(status_code=401, detail="Not authenticated")
po = await _load_po(db, po_id, current_user.kitchen_id)

View file

@ -10,7 +10,7 @@ from decimal import Decimal
from typing import Optional
from html import escape as html_escape
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, text, and_, delete
@ -35,7 +35,7 @@ logger = logging.getLogger(__name__)
router = APIRouter()
DATA_DIR = "/app/data"
DATA_DIR = os.getenv("DATA_DIR", "/app/data")
# ── Pydantic schemas ─────────────────────────────────────────────────────────
@ -686,16 +686,33 @@ async def list_recipes(
result = await db.execute(query.order_by(Recipe.name))
recipes = result.scalars().all()
# Batch-load latest cost snapshot per recipe (1 query vs N)
recipe_ids = [r.id for r in recipes]
snap_map: dict[int, RecipeCostSnapshot] = {}
if recipe_ids:
latest_subq = (
select(
RecipeCostSnapshot.recipe_id,
func.max(RecipeCostSnapshot.snapshot_date).label("max_date"),
)
.where(RecipeCostSnapshot.recipe_id.in_(recipe_ids))
.group_by(RecipeCostSnapshot.recipe_id)
.subquery()
)
snap_rows = await db.execute(
select(RecipeCostSnapshot).join(
latest_subq,
and_(
RecipeCostSnapshot.recipe_id == latest_subq.c.recipe_id,
RecipeCostSnapshot.snapshot_date == latest_subq.c.max_date,
),
)
)
snap_map = {s.recipe_id: s for s in snap_rows.scalars().all()}
items = []
for r in recipes:
# Get latest cost snapshot
snap_result = await db.execute(
select(RecipeCostSnapshot)
.where(RecipeCostSnapshot.recipe_id == r.id)
.order_by(RecipeCostSnapshot.snapshot_date.desc())
.limit(1)
)
snap = snap_result.scalar_one_or_none()
snap = snap_map.get(r.id)
# Get flag summary (lightweight)
from api.food_flags import compute_recipe_flags
@ -1529,13 +1546,18 @@ async def upload_image(
async def serve_image(
recipe_id: int,
image_id: int,
request: Request,
token: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db),
):
# img tags can't send Authorization header, so auth via query param
if not token:
raise HTTPException(401, "Not authenticated")
user = await get_current_user_from_token(token, db)
user = None
if token:
user = await get_current_user_from_token(token, db)
if not user:
try:
user = await get_current_user(request)
except HTTPException:
pass
if not user:
raise HTTPException(401, "Not authenticated")
await _get_recipe(recipe_id, user.kitchen_id, db)
@ -1728,6 +1750,8 @@ async def _calc_recipe_cost(recipe_id: int, db: AsyncSession, scale_to: Optional
needed_unit = sr.portions_needed_unit or child_output_unit
portions_needed_raw = float(sr.portions_needed) * scale_factor
portions_needed = _convert_unit(portions_needed_raw, needed_unit, child_output_unit)
if not child_output_qty:
logger.warning(f"Recipe {child.id} ({child.name!r}) has zero yield qty — cost contribution zeroed in parent recipe")
scale_ratio = portions_needed / child_output_qty if child_output_qty else 0
cost_contribution = float(child_total) * scale_ratio if child_total else None
cost_contribution_min = float(child_total_min) * scale_ratio if child_total_min else None
@ -2098,14 +2122,19 @@ async def backfill_invoice_references(
@router.get("/{recipe_id}/print")
async def print_recipe(
recipe_id: int,
request: Request,
format: str = Query("full"), # "full" | "kitchen"
token: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db),
):
# window.open() can't send Authorization header, so auth via query param
if not token:
raise HTTPException(status_code=401, detail="Not authenticated")
user = await get_current_user_from_token(token, db)
user = None
if token:
user = await get_current_user_from_token(token, db)
if not user:
try:
user = await get_current_user(request)
except HTTPException:
pass
if not user:
raise HTTPException(status_code=401, detail="Not authenticated")
recipe = await _get_recipe(recipe_id, user.kitchen_id, db)
@ -2116,11 +2145,11 @@ async def print_recipe(
from api.food_flags import compute_recipe_flags
flags = await compute_recipe_flags(recipe_id, user.kitchen_id, db)
html = _build_recipe_html(full_data, cost_data, flags, format, recipe_id=recipe_id, token=token)
html = _build_recipe_html(full_data, cost_data, flags, format, recipe_id=recipe_id)
return HTMLResponse(content=html)
def _build_recipe_html(recipe_data: dict, cost_data: dict, flags, format: str = "full", recipe_id: int = 0, token: str = "") -> str:
def _build_recipe_html(recipe_data: dict, cost_data: dict, flags, format: str = "full", recipe_id: int = 0) -> str:
"""Generate print-optimised HTML for a recipe."""
esc = html_escape
name = esc(recipe_data.get("name", ""))
@ -2204,7 +2233,7 @@ def _build_recipe_html(recipe_data: dict, cost_data: dict, flags, format: str =
if not plating_images:
plating_images = recipe_data.get("images", [])[:1]
for img in plating_images:
img_url = f"/api/recipes/{recipe_id}/images/{img['id']}?token={token}"
img_url = f"/kitchen/api/recipes/{recipe_id}/images/{img['id']}"
images_html += f'<img src="{esc(img_url)}" style="max-width:300px;border-radius:8px;margin:10px 0;" />'
time_info = ""

View file

@ -14,9 +14,9 @@ if DATABASE_URL.startswith("postgresql://"):
engine = create_async_engine(
DATABASE_URL,
echo=False,
pool_size=10, # Default is 5
max_overflow=20, # Default is 10 - allows burst to 30 connections
pool_pre_ping=True # Verify connections are alive before use
pool_size=int(os.getenv("DB_POOL_SIZE", "10")),
max_overflow=int(os.getenv("DB_MAX_OVERFLOW", "20")),
pool_pre_ping=True,
)
AsyncSessionLocal = async_sessionmaker(