""" Inventory item and storage-bin endpoints. Storage bins ------------- POST /bins – create a bin (any authenticated user) GET /bins – list bins (admin: all bins; customer: own bins only) GET /bins/{id} – get one bin PATCH /bins/{id} – update bin (owner or admin) DELETE /bins/{id} – delete bin (owner or admin) Items ------ POST /items – create an item (any authenticated user) GET /items – list items with filters (admin: all; customer: own) GET /items/{id} – get one item PATCH /items/{id} – update item (creator or admin) DELETE /items/{id} – delete item (admin only – destructive action) Access control philosophy -------------------------- Customers can manage their own bins and the items they created. Admins can see and edit everything. This keeps the app useful for small teams where everyone is adding their own pile of stuff. """ from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.orm import Session from app.auth.security import get_current_user, require_admin from app.database import get_db from app.models.item import Item, ItemStatus, StorageBin from app.models.user import User from app.schemas.item import ( ItemCreate, ItemListResponse, ItemRead, ItemUpdate, StorageBinCreate, StorageBinRead, StorageBinUpdate, ) router = APIRouter(tags=["inventory"]) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _get_bin_or_404(bin_id: int, db: Session) -> StorageBin: obj = db.query(StorageBin).filter(StorageBin.id == bin_id).first() if obj is None: raise HTTPException(status_code=404, detail=f"Storage bin {bin_id} not found") return obj def _get_item_or_404(item_id: int, db: Session) -> Item: obj = db.query(Item).filter(Item.id == item_id).first() if obj is None: raise HTTPException(status_code=404, detail=f"Item {item_id} not found") return obj def _assert_bin_access(bin: StorageBin, user: User) -> None: """Raise 403 if ``user`` is not allowed to modify ``bin``.""" if not user.is_admin and bin.owner_id != user.id: raise HTTPException(status_code=403, detail="Not your storage bin") def _assert_item_write_access(item: Item, user: User) -> None: """Raise 403 if ``user`` is not allowed to modify ``item``.""" if not user.is_admin and item.created_by_id != user.id: raise HTTPException(status_code=403, detail="Not your item") # --------------------------------------------------------------------------- # Storage Bin endpoints # --------------------------------------------------------------------------- @router.post("/bins", response_model=StorageBinRead, status_code=201, tags=["bins"]) def create_bin( body: StorageBinCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ) -> StorageBin: """ Create a new storage bin owned by the currently logged-in user. Any authenticated user can create bins for their own pile. """ new_bin = StorageBin( name=body.name, description=body.description, owner_id=current_user.id, ) db.add(new_bin) db.commit() db.refresh(new_bin) return new_bin @router.get("/bins", response_model=list[StorageBinRead], tags=["bins"]) def list_bins( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ) -> list[StorageBin]: """ List storage bins. Admins see every bin. Customers see only their own. """ q = db.query(StorageBin) if not current_user.is_admin: q = q.filter(StorageBin.owner_id == current_user.id) return q.order_by(StorageBin.created_at.desc()).all() @router.get("/bins/{bin_id}", response_model=StorageBinRead, tags=["bins"]) def get_bin( bin_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ) -> StorageBin: """Return one storage bin. Customers can only see their own.""" obj = _get_bin_or_404(bin_id, db) if not current_user.is_admin and obj.owner_id != current_user.id: raise HTTPException(status_code=403, detail="Not your storage bin") return obj @router.patch("/bins/{bin_id}", response_model=StorageBinRead, tags=["bins"]) def update_bin( bin_id: int, body: StorageBinUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ) -> StorageBin: """Update a storage bin's name or description.""" obj = _get_bin_or_404(bin_id, db) _assert_bin_access(obj, current_user) for field, value in body.model_dump(exclude_unset=True).items(): setattr(obj, field, value) db.commit() db.refresh(obj) return obj @router.delete("/bins/{bin_id}", status_code=204, tags=["bins"]) def delete_bin( bin_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ) -> None: """ Delete a storage bin. Items inside the bin are NOT deleted – their storage_bin_id is set to NULL by the FK ``ON DELETE SET NULL`` rule, so they become "un-binned". """ obj = _get_bin_or_404(bin_id, db) _assert_bin_access(obj, current_user) db.delete(obj) db.commit() # --------------------------------------------------------------------------- # Item endpoints # --------------------------------------------------------------------------- @router.post("/items", response_model=ItemRead, status_code=201, tags=["items"]) def create_item( body: ItemCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ) -> Item: """ Add a new inventory item. The creating user is recorded as ``created_by_id``. Only ``title`` is required – all other fields default to None / FOUND so items can be entered quickly during a sorting session. """ # Validate that the referenced bin exists and belongs to the caller. if body.storage_bin_id is not None: the_bin = db.query(StorageBin).filter( StorageBin.id == body.storage_bin_id ).first() if the_bin is None: raise HTTPException(status_code=404, detail="Storage bin not found") if not current_user.is_admin and the_bin.owner_id != current_user.id: raise HTTPException( status_code=403, detail="Cannot place item in someone else's bin" ) item = Item( **body.model_dump(), created_by_id=current_user.id, updated_by_id=current_user.id, ) db.add(item) db.commit() db.refresh(item) return item @router.get("/items", response_model=ItemListResponse, tags=["items"]) def list_items( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), page: int = Query(1, ge=1, description="Page number (1-indexed)"), page_size: int = Query(20, ge=1, le=100, description="Items per page"), status_filter: ItemStatus | None = Query( None, alias="status", description="Filter by lifecycle status" ), search: str | None = Query(None, description="Full-text search on title"), ) -> dict: """ List inventory items with optional filtering and pagination. - Admins see all items. - Customers see only items they created. - Filter by ?status=found|listed|sold|archived - Filter by ?search=keyword (case-insensitive title match) """ q = db.query(Item) if not current_user.is_admin: q = q.filter(Item.created_by_id == current_user.id) if status_filter is not None: q = q.filter(Item.status == status_filter) if search: q = q.filter(Item.title.ilike(f"%{search}%")) total = q.count() offset = (page - 1) * page_size items = q.order_by(Item.created_at.desc()).offset(offset).limit(page_size).all() return {"total": total, "page": page, "page_size": page_size, "items": items} @router.get("/items/{item_id}", response_model=ItemRead, tags=["items"]) def get_item( item_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ) -> Item: """Return a single item. Customers can only see items they created.""" item = _get_item_or_404(item_id, db) if not current_user.is_admin and item.created_by_id != current_user.id: raise HTTPException(status_code=403, detail="Not your item") return item @router.patch("/items/{item_id}", response_model=ItemRead, tags=["items"]) def update_item( item_id: int, body: ItemUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ) -> Item: """ Update item fields. Typical use-cases: - Change status from FOUND → LISTED after photographing - Add listing_url after posting on eBay - Record sale_price and flip to SOLD after it sells """ item = _get_item_or_404(item_id, db) _assert_item_write_access(item, current_user) for field, value in body.model_dump(exclude_unset=True).items(): setattr(item, field, value) item.updated_by_id = current_user.id db.commit() db.refresh(item) return item @router.delete("/items/{item_id}", status_code=204, tags=["items"]) def delete_item( item_id: int, db: Session = Depends(get_db), _admin: User = Depends(require_admin), ) -> None: """ Hard-delete an item. Admin only. Customers should use PATCH to set status=ARCHIVED instead. This endpoint exists for admins to clean up test data or genuine data-entry mistakes. """ item = _get_item_or_404(item_id, db) db.delete(item) db.commit()