working with login and building inventory

This commit is contained in:
2026-11-12 11:40:31 -07:00
parent d11e5e70c7
commit 0a93925e11
45 changed files with 2389 additions and 77 deletions

View File

@@ -0,0 +1,227 @@
"""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: 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,
"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,
"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()
@router.get("", response_model=InventoryPage)
def list_inventory(
category: str | None = None,
search: str | None = None,
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(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=lambda i: (i["name"].lower(), i["class_name"]))
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)), "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
db.commit()
db.refresh(entry)
return InventoryEntryOut.model_validate(_enrich(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)