Files
flipventory_v2/app/routers/users.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

127 lines
3.8 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.
"""
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()