created new role
This commit is contained in:
286
roles/trade_hub/files/trade-hub/backend/app/inventory.py
Normal file
286
roles/trade_hub/files/trade-hub/backend/app/inventory.py
Normal file
@@ -0,0 +1,286 @@
|
||||
"""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,
|
||||
"quality": entry.quality,
|
||||
"quality_applicable": False,
|
||||
"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,
|
||||
"quality": entry.quality,
|
||||
"quality_applicable": item.get("quality_applicable", False),
|
||||
"visibility": entry.visibility,
|
||||
"available": True,
|
||||
"created_at": entry.created_at,
|
||||
"updated_at": entry.updated_at,
|
||||
}
|
||||
|
||||
|
||||
def _validate_quality(item: dict, quality: int | None) -> None:
|
||||
if quality is not None and not item.get("quality_applicable", False):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Quality does not apply to '{item['name']}'",
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
item = catalog.store.by_class_name.get(class_name)
|
||||
if item is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Catalog item '{class_name}' not found"
|
||||
)
|
||||
_validate_quality(item, payload.quality)
|
||||
|
||||
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,
|
||||
quality=payload.quality,
|
||||
)
|
||||
db.add(entry)
|
||||
else:
|
||||
entry.quantity += payload.quantity
|
||||
if payload.notes:
|
||||
entry.notes = payload.notes
|
||||
if payload.quality is not None:
|
||||
entry.quality = payload.quality
|
||||
|
||||
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
|
||||
if payload.quality is not None:
|
||||
entry.quality = payload.quality
|
||||
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
|
||||
if "quality" in payload.model_fields_set:
|
||||
# Explicit null clears the value; omitted keeps the existing one.
|
||||
if payload.quality is not None:
|
||||
item = catalog.store.by_class_name.get(entry.class_name)
|
||||
if item is None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Quality does not apply to '{entry.class_name}'",
|
||||
)
|
||||
_validate_quality(item, payload.quality)
|
||||
entry.quality = payload.quality
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user