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

0
app/schemas/__init__.py Normal file
View File

112
app/schemas/item.py Normal file
View File

@@ -0,0 +1,112 @@
"""
Pydantic schemas for Item and StorageBin resources.
Design choices
---------------
- All price fields are optional: items can be added quickly without price info.
- ``status`` uses the ItemStatus enum so the API documents the allowed values.
- The ``*Read`` schemas include nested owner / bin info so clients don't need
to make extra requests.
"""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, HttpUrl
from app.models.item import ItemStatus
# ---------------------------------------------------------------------------
# StorageBin schemas
# ---------------------------------------------------------------------------
class StorageBinCreate(BaseModel):
"""Request body for POST /bins."""
name: str
description: str | None = None
class StorageBinRead(BaseModel):
"""Response schema for a storage bin."""
model_config = ConfigDict(from_attributes=True)
id: int
name: str
description: str | None
owner_id: int
created_at: datetime
class StorageBinUpdate(BaseModel):
"""Request body for PATCH /bins/{id}."""
name: str | None = None
description: str | None = None
# ---------------------------------------------------------------------------
# Item schemas
# ---------------------------------------------------------------------------
class ItemCreate(BaseModel):
"""
Request body for POST /items.
Only ``title`` is required so that items can be added quickly during a
"chaos-dump" session. Everything else can be filled in later.
"""
title: str
description: str | None = None
status: ItemStatus = ItemStatus.FOUND
purchase_price: float | None = None
listing_price: float | None = None
sale_price: float | None = None
listing_url: str | None = None
sku: str | None = None
storage_bin_id: int | None = None
class ItemRead(BaseModel):
"""Full response schema for an item."""
model_config = ConfigDict(from_attributes=True)
id: int
title: str
description: str | None
status: ItemStatus
purchase_price: float | None
listing_price: float | None
sale_price: float | None
listing_url: str | None
sku: str | None
storage_bin_id: int | None
created_by_id: int | None
updated_by_id: int | None
created_at: datetime
updated_at: datetime
class ItemUpdate(BaseModel):
"""
Request body for PATCH /items/{id}.
All fields optional so callers can update just one field at a time
(e.g., only the status when marking something sold).
"""
title: str | None = None
description: str | None = None
status: ItemStatus | None = None
purchase_price: float | None = None
listing_price: float | None = None
sale_price: float | None = None
listing_url: str | None = None
sku: str | None = None
storage_bin_id: int | None = None
class ItemListResponse(BaseModel):
"""Paginated list response for GET /items."""
total: int
page: int
page_size: int
items: list[ItemRead]

67
app/schemas/user.py Normal file
View File

@@ -0,0 +1,67 @@
"""
Pydantic schemas for the User resource.
Schemas vs. models
-------------------
SQLAlchemy *models* define how data is stored in the database.
Pydantic *schemas* define what comes in over the API (request bodies) and
what goes out (response shapes). Keeping them separate means we can expose
only the fields we want and add validation logic without touching the DB layer.
"""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, EmailStr, field_validator
class UserCreate(BaseModel):
"""Request body for POST /auth/register."""
email: EmailStr
display_name: str
password: str
@field_validator("password")
@classmethod
def password_min_length(cls, v: str) -> str:
if len(v) < 8:
raise ValueError("Password must be at least 8 characters long")
return v
@field_validator("display_name")
@classmethod
def display_name_not_empty(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("Display name must not be empty")
return v
class UserRead(BaseModel):
"""Response schema safe to expose publicly (no password hash)."""
model_config = ConfigDict(from_attributes=True)
id: int
email: EmailStr
display_name: str
is_admin: bool
is_active: bool
created_at: datetime
class UserUpdate(BaseModel):
"""Request body for PATCH /users/{id} all fields optional."""
display_name: str | None = None
is_active: bool | None = None
# Admins can promote/demote other users here; the business rule about
# settings.admin_email always being admin is enforced in the router.
is_admin: bool | None = None
class TokenResponse(BaseModel):
"""Response body for POST /auth/login."""
access_token: str
token_type: str = "bearer"
user: UserRead