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>
299 lines
9.6 KiB
Python
299 lines
9.6 KiB
Python
"""
|
||
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()
|