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