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>
This commit is contained in:
0
app/routers/__init__.py
Normal file
0
app/routers/__init__.py
Normal file
113
app/routers/auth.py
Normal file
113
app/routers/auth.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
Authentication endpoints.
|
||||
|
||||
POST /auth/register – create a new account (always customer, unless the
|
||||
registered e-mail matches settings.admin_email)
|
||||
POST /auth/login – exchange credentials for a JWT Bearer token
|
||||
GET /auth/me – return the currently logged-in user's profile
|
||||
|
||||
Login flow
|
||||
-----------
|
||||
1. Client POSTs e-mail + password as form data (OAuth2 compatible).
|
||||
2. We look up the user, verify the password, and issue a JWT.
|
||||
3. The admin e-mail guard: if this is the hardcoded admin address, we force
|
||||
is_admin=True in the DB on every login. This prevents accidental lockout.
|
||||
4. Client stores the token and sends it as ``Authorization: Bearer <token>``
|
||||
on subsequent requests.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth.security import (
|
||||
create_access_token,
|
||||
get_current_user,
|
||||
hash_password,
|
||||
verify_password,
|
||||
)
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.user import TokenResponse, UserCreate, UserRead
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserRead, status_code=status.HTTP_201_CREATED)
|
||||
def register(body: UserCreate, db: Session = Depends(get_db)) -> User:
|
||||
"""
|
||||
Register a new user account.
|
||||
|
||||
- E-mail must be unique.
|
||||
- If the e-mail matches the configured admin address the account is
|
||||
immediately given admin rights (regardless of what the caller sends).
|
||||
- All other accounts are customers (is_admin=False).
|
||||
"""
|
||||
# Reject duplicate e-mails early with a clear error.
|
||||
existing = db.query(User).filter(User.email == body.email).first()
|
||||
if existing is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="An account with this e-mail already exists",
|
||||
)
|
||||
|
||||
# The hardcoded admin e-mail is always an admin.
|
||||
is_admin = body.email.lower() == settings.admin_email.lower()
|
||||
|
||||
user = User(
|
||||
email=body.email.lower(),
|
||||
display_name=body.display_name,
|
||||
hashed_password=hash_password(body.password),
|
||||
is_admin=is_admin,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""
|
||||
Log in and receive a JWT Bearer token.
|
||||
|
||||
Uses the standard OAuth2 ``application/x-www-form-urlencoded`` format so
|
||||
that Swagger UI's "Authorize" button works without extra setup.
|
||||
|
||||
Fields: ``username`` (= e-mail), ``password``
|
||||
"""
|
||||
# username field holds the e-mail (OAuth2 standard naming)
|
||||
user = db.query(User).filter(User.email == form_data.username.lower()).first()
|
||||
|
||||
if user is None or not verify_password(form_data.password, user.hashed_password):
|
||||
# Return the same generic message for both "user not found" and
|
||||
# "wrong password" to avoid leaking which e-mail addresses exist.
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect e-mail or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Account is disabled – contact the administrator",
|
||||
)
|
||||
|
||||
# Enforce the "admin e-mail is always admin" rule on every login.
|
||||
if user.email == settings.admin_email.lower() and not user.is_admin:
|
||||
user.is_admin = True
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
token = create_access_token(subject=user.email)
|
||||
return {"access_token": token, "token_type": "bearer", "user": user}
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserRead)
|
||||
def get_me(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""Return the profile of the currently authenticated user."""
|
||||
return current_user
|
||||
298
app/routers/items.py
Normal file
298
app/routers/items.py
Normal file
@@ -0,0 +1,298 @@
|
||||
"""
|
||||
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()
|
||||
126
app/routers/users.py
Normal file
126
app/routers/users.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""
|
||||
User management endpoints (admin only).
|
||||
|
||||
GET /users – list all users
|
||||
GET /users/{id} – get one user
|
||||
PATCH /users/{id} – update display name, active status, or admin flag
|
||||
DELETE /users/{id} – soft-delete (deactivate) a user
|
||||
|
||||
All endpoints require admin role. Self-deactivation / self-demotion is
|
||||
blocked to prevent admins from accidentally locking themselves out.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth.security import require_admin
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserRead, UserUpdate
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
def _get_user_or_404(user_id: int, db: Session) -> User:
|
||||
"""Fetch a user by PK or raise 404."""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User {user_id} not found",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@router.get("", response_model=list[UserRead])
|
||||
def list_users(
|
||||
db: Session = Depends(get_db),
|
||||
_admin: User = Depends(require_admin),
|
||||
) -> list[User]:
|
||||
"""Return all registered users. Admin only."""
|
||||
return db.query(User).order_by(User.created_at.desc()).all()
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserRead)
|
||||
def get_user(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_admin: User = Depends(require_admin),
|
||||
) -> User:
|
||||
"""Return a single user by ID. Admin only."""
|
||||
return _get_user_or_404(user_id, db)
|
||||
|
||||
|
||||
@router.patch("/{user_id}", response_model=UserRead)
|
||||
def update_user(
|
||||
user_id: int,
|
||||
body: UserUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(require_admin),
|
||||
) -> User:
|
||||
"""
|
||||
Update a user's display name, active state, or admin flag.
|
||||
|
||||
Guards:
|
||||
- Cannot demote the permanent admin e-mail address.
|
||||
- Cannot deactivate your own account (you'd be locked out).
|
||||
"""
|
||||
user = _get_user_or_404(user_id, db)
|
||||
|
||||
# Block demotion of the hardcoded admin.
|
||||
if (
|
||||
user.email == settings.admin_email.lower()
|
||||
and body.is_admin is not None
|
||||
and body.is_admin is False
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot demote the permanent admin account",
|
||||
)
|
||||
|
||||
# Block self-deactivation.
|
||||
if user.id == admin.id and body.is_active is False:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot deactivate your own account",
|
||||
)
|
||||
|
||||
# Apply updates – only fields explicitly provided in the request body.
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(user, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def deactivate_user(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(require_admin),
|
||||
) -> None:
|
||||
"""
|
||||
Soft-delete a user by setting is_active=False.
|
||||
|
||||
We do not hard-delete because items reference users via created_by_id /
|
||||
updated_by_id and we need the audit trail to remain intact.
|
||||
|
||||
Cannot deactivate the permanent admin or yourself.
|
||||
"""
|
||||
user = _get_user_or_404(user_id, db)
|
||||
|
||||
if user.email == settings.admin_email.lower():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot deactivate the permanent admin account",
|
||||
)
|
||||
if user.id == admin.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot deactivate your own account",
|
||||
)
|
||||
|
||||
user.is_active = False
|
||||
db.commit()
|
||||
Reference in New Issue
Block a user