Files
flipventory_v2/app/routers/items.py
Martin Hohenberg 4e0d0d3962 feat: initial FastAPI stub with auth, user management, and inventory CRUD
Sets up the complete application skeleton for Flipventory v2, an inventory
management tool for small/medium resellers using chaotic stockpiling.

Architecture
------------
- app/config.py         pydantic-settings; reads .env; holds admin_email guard
- app/database.py       SQLAlchemy 2.x engine, SessionLocal, Base, get_db dep
- app/models/user.py    User (email, bcrypt hash, is_admin bool, is_active)
- app/models/item.py    StorageBin + Item with FOUND/LISTED/SOLD/ARCHIVED enum
- app/schemas/          Pydantic request/response shapes (separate from models)
- app/auth/security.py  bcrypt password hashing + HS256 JWT creation/validation
- app/routers/auth.py   POST /auth/register, POST /auth/login, GET /auth/me
- app/routers/users.py  Admin-only CRUD for user accounts
- app/routers/items.py  /bins and /items CRUD with role-based access control

Key design decisions
--------------------
- martin@hohenberg.jp is ALWAYS admin: enforced on every login, cannot be
  demoted, cannot be deactivated. Prevents accidental lockout.
- All other registered e-mails are customers (is_admin=False).
- Customers can manage their own bins and items; admins see everything.
- Item hard-delete is admin-only; customers archive instead (audit safety).
- SQLite default for zero-infra local dev; any SQLAlchemy URL works in prod.
- Tables are created on startup via create_tables(); swap for Alembic later.

Addresses v1 Gitea issues
--------------------------
- Issue #1 (storage bins): StorageBin model + /bins CRUD endpoints
- Issue #2 (item CRUD): Item model + /items CRUD endpoints
- Issue #3 (audit logging): created_by_id / updated_by_id on every item row

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 10:40:53 +02:00

299 lines
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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()