"""Per-user inventory routes. Rows reference immutable catalog items by `class_name`; responses are enriched live from the in-memory catalog. A missing catalog item is reported as `available: false` instead of deleting the user's data. """ import math from fastapi import APIRouter, Depends, HTTPException, Query, Response from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session as DbSession from app import catalog from app.db import get_db from app.deps import get_current_user, require_same_origin from app.models import InventoryEntry, User from app.schemas import ( InventoryAddRequest, InventoryAddResponse, InventoryEntryOut, InventoryPage, InventoryPatchRequest, InventorySummary, ) router = APIRouter( prefix="/api/v1/me/inventory", tags=["inventory"], dependencies=[Depends(require_same_origin)], ) MAX_PAGE_SIZE = 100 def enrich_entry(entry: InventoryEntry) -> dict: item = catalog.store.by_class_name.get(entry.class_name) if item is None: return { "class_name": entry.class_name, "name": entry.class_name.replace("_", " "), "category": None, "type": None, "size": None, "grade": None, "manufacturer": None, "quantity": entry.quantity, "notes": entry.notes, "visibility": entry.visibility, "available": False, "created_at": entry.created_at, "updated_at": entry.updated_at, } return { "class_name": entry.class_name, "name": item["name"], "category": item["category"], "type": item["type"], "size": item["size"], "grade": item["grade"], "manufacturer": item["manufacturer"], "quantity": entry.quantity, "notes": entry.notes, "visibility": entry.visibility, "available": True, "created_at": entry.created_at, "updated_at": entry.updated_at, } def _find(db: DbSession, user: User, class_name: str) -> InventoryEntry | None: return db.execute( select(InventoryEntry).where( InventoryEntry.user_id == user.id, InventoryEntry.class_name == class_name, ) ).scalar_one_or_none() def _sort_key_name(item): return (item["name"].lower(), item["class_name"]) def _sort_key_category(item): return ((item["category"] or "").lower(), item["name"].lower(), item["class_name"]) def _sort_key_quantity(item): return (-item["quantity"], item["name"].lower(), item["class_name"]) def _sort_key_updated(item): return (-item["updated_at"].timestamp(), item["name"].lower()) SORT_KEYS = { "name": _sort_key_name, "category": _sort_key_category, "quantity": _sort_key_quantity, "updated": _sort_key_updated, } @router.get("", response_model=InventoryPage) def list_inventory( category: str | None = None, search: str | None = None, sort: str = Query(default="name", pattern="^(name|category|quantity|updated)$"), page: int = Query(default=1, ge=1), page_size: int = Query(default=25, ge=1), user: User = Depends(get_current_user), db: DbSession = Depends(get_db), ): page_size = min(page_size, MAX_PAGE_SIZE) rows = db.execute( select(InventoryEntry).where(InventoryEntry.user_id == user.id) ).scalars().all() items = [enrich_entry(row) for row in rows] if category is not None and category.strip(): wanted = category.strip() items = [i for i in items if i["category"] == wanted] if search is not None and search.strip(): needle = search.strip().lower() items = [ i for i in items if needle in i["name"].lower() or needle in i["class_name"].lower() ] items.sort(key=SORT_KEYS[sort]) total = len(items) total_pages = max(1, math.ceil(total / page_size)) start = (page - 1) * page_size return { "total": total, "page": page, "page_size": page_size, "total_pages": total_pages, "items": items[start : start + page_size], } @router.get("/summary", response_model=InventorySummary) def inventory_summary( user: User = Depends(get_current_user), db: DbSession = Depends(get_db) ): rows = db.execute( select(InventoryEntry).where(InventoryEntry.user_id == user.id) ).scalars().all() categories: dict[str, int] = {} unavailable = 0 total_quantity = 0 for row in rows: item = catalog.store.by_class_name.get(row.class_name) if item is None: unavailable += 1 else: key = item["category"] categories[key] = categories.get(key, 0) + row.quantity total_quantity += row.quantity return { "items": len(rows), "quantity": total_quantity, "categories": categories, "unavailable": unavailable, } @router.post("", response_model=InventoryAddResponse) def add_to_inventory( payload: InventoryAddRequest, response: Response, user: User = Depends(get_current_user), db: DbSession = Depends(get_db), ): class_name = payload.class_name.strip() if class_name not in catalog.store.by_class_name: raise HTTPException( status_code=404, detail=f"Catalog item '{class_name}' not found" ) entry = _find(db, user, class_name) created = entry is None if created: entry = InventoryEntry( user_id=user.id, class_name=class_name, quantity=payload.quantity, notes=payload.notes, ) db.add(entry) else: entry.quantity += payload.quantity if payload.notes: entry.notes = payload.notes try: db.commit() except IntegrityError: # Lost a race against a concurrent add: re-read and increment instead. db.rollback() entry = _find(db, user, class_name) if entry is None: raise HTTPException(status_code=409, detail="Could not add item; retry") entry.quantity += payload.quantity if payload.notes: entry.notes = payload.notes db.commit() created = False db.refresh(entry) response.status_code = 201 if created else 200 return {"entry": InventoryEntryOut.model_validate(enrich_entry(entry)), "created": created} @router.patch("/{class_name}", response_model=InventoryEntryOut) def update_inventory_entry( class_name: str, payload: InventoryPatchRequest, user: User = Depends(get_current_user), db: DbSession = Depends(get_db), ): entry = _find(db, user, class_name) if entry is None: raise HTTPException(status_code=404, detail="Item is not in your inventory") if payload.quantity is not None: entry.quantity = payload.quantity if payload.notes is not None: entry.notes = payload.notes if payload.visibility is not None: entry.visibility = payload.visibility db.commit() db.refresh(entry) return InventoryEntryOut.model_validate(enrich_entry(entry)) @router.delete("/{class_name}", status_code=204) def remove_inventory_entry( class_name: str, user: User = Depends(get_current_user), db: DbSession = Depends(get_db), ): entry = _find(db, user, class_name) if entry is None: raise HTTPException(status_code=404, detail="Item is not in your inventory") db.delete(entry) db.commit() return Response(status_code=204)