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:
Martin Hohenberg
2026-06-16 10:40:53 +02:00
parent fe774c924f
commit 4e0d0d3962
19 changed files with 1332 additions and 0 deletions

126
app/routers/users.py Normal file
View 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()