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

113
app/routers/auth.py Normal file
View 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