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

18
.env.example Normal file
View File

@@ -0,0 +1,18 @@
# Copy this to .env and fill in real values.
# Never commit .env to version control.
# Secret key for signing JWT tokens generate with: openssl rand -hex 32
SECRET_KEY=change-me-generate-with-openssl-rand-hex-32
# JWT algorithm (HS256 is fine for single-server setups)
ALGORITHM=HS256
# How many minutes a login token stays valid
ACCESS_TOKEN_EXPIRE_MINUTES=60
# SQLite path relative to the project root (or a full postgresql:// URL)
DATABASE_URL=sqlite:///./flipventory.db
# The email address that is always treated as the site-wide admin.
# This user gets admin=True forced on every login, regardless of DB state.
ADMIN_EMAIL=martin@hohenberg.jp

27
.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
# Python bytecode
__pycache__/
*.py[cod]
*.pyo
# Virtual environments
.venv/
venv/
env/
# Environment / secrets
.env
# SQLite database (runtime artefact not source)
*.db
*.db-shm
*.db-wal
# Editor / OS noise
.DS_Store
.idea/
.vscode/
*.swp
# Alembic generated migration artefacts that should not be committed carelessly
# (comment this out once you start using Alembic for real)
# alembic/versions/*.py

0
app/__init__.py Normal file
View File

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

139
app/auth/security.py Normal file
View File

@@ -0,0 +1,139 @@
"""
Authentication helpers: password hashing and JWT token management.
Password hashing
-----------------
We use passlib's bcrypt scheme. bcrypt is intentionally slow (tunable cost
factor) which makes brute-force attacks against the hash database expensive.
Never store or log plaintext passwords.
JWT tokens
-----------
Tokens are signed with HS256 (HMAC-SHA256). The payload contains:
- ``sub`` the user's e-mail address (the "subject")
- ``exp`` expiry timestamp (unix epoch seconds)
The token is passed by the client in the ``Authorization: Bearer <token>``
header. FastAPI's OAuth2PasswordBearer extracts the raw token string for us.
"""
from datetime import datetime, timedelta, timezone
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.models.user import User
# ---------------------------------------------------------------------------
# Password helpers
# ---------------------------------------------------------------------------
# CryptContext manages the algorithm list. If we ever want to upgrade (e.g.,
# argon2) we can add it to the list and passlib will migrate hashes on login.
_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plaintext: str) -> str:
"""Return the bcrypt hash of ``plaintext``."""
return _pwd_context.hash(plaintext)
def verify_password(plaintext: str, hashed: str) -> bool:
"""Return True if ``plaintext`` matches ``hashed``."""
return _pwd_context.verify(plaintext, hashed)
# ---------------------------------------------------------------------------
# JWT helpers
# ---------------------------------------------------------------------------
def create_access_token(subject: str) -> str:
"""
Create a signed JWT token for ``subject`` (the user's e-mail).
The token expires after ``settings.access_token_expire_minutes`` minutes
from the time of creation.
"""
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.access_token_expire_minutes
)
payload = {"sub": subject, "exp": expire}
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
def _decode_token(token: str) -> str:
"""
Decode and validate a JWT token.
Returns the ``sub`` claim (e-mail) on success.
Raises HTTPException(401) if the token is invalid or expired.
"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(
token, settings.secret_key, algorithms=[settings.algorithm]
)
email: str | None = payload.get("sub")
if email is None:
raise credentials_exception
return email
except JWTError:
raise credentials_exception
# ---------------------------------------------------------------------------
# FastAPI dependencies
# ---------------------------------------------------------------------------
# tokenUrl must match the path of our login endpoint so Swagger UI's
# "Authorize" button works.
_oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
def get_current_user(
token: str = Depends(_oauth2_scheme),
db: Session = Depends(get_db),
) -> User:
"""
Dependency: resolve a Bearer token to a User ORM object.
Raises 401 if the token is invalid/expired.
Raises 403 if the account is disabled.
"""
email = _decode_token(token)
user = db.query(User).filter(User.email == email).first()
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account is disabled",
)
return user
def require_admin(current_user: User = Depends(get_current_user)) -> User:
"""
Dependency: ensure the logged-in user is an admin.
Raises 403 if they are not. Use this on any endpoint that should be
admin-only.
"""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
return current_user

45
app/config.py Normal file
View File

@@ -0,0 +1,45 @@
"""
Application-wide settings loaded from environment variables (or a .env file).
pydantic-settings reads the .env file automatically when BaseSettings is used,
so there is no need to call load_dotenv() manually.
"""
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# -------------------------------------------------------------------------
# Auth / Security
# -------------------------------------------------------------------------
# Signing secret for JWT tokens. Must be kept private on the server.
secret_key: str = "insecure-dev-secret-replace-in-production"
# JWT signing algorithm. HS256 is a symmetric HMAC-SHA256 scheme which
# is perfectly fine for a single-server application.
algorithm: str = "HS256"
# Token lifetime in minutes. Tokens expire after this period and the
# user must log in again.
access_token_expire_minutes: int = 60
# -------------------------------------------------------------------------
# Database
# -------------------------------------------------------------------------
# Any SQLAlchemy-compatible URL. Defaults to a local SQLite file so the
# app works out-of-the-box without extra infrastructure.
database_url: str = "sqlite:///./flipventory.db"
# -------------------------------------------------------------------------
# Business rules
# -------------------------------------------------------------------------
# The e-mail address that is ALWAYS treated as the site-wide administrator.
# Even if the database row for this user somehow has admin=False, every
# login will force-set it back to True.
admin_email: str = "martin@hohenberg.jp"
class Config:
env_file = ".env"
# Module-level singleton import this everywhere instead of re-instantiating.
settings = Settings()

64
app/database.py Normal file
View File

@@ -0,0 +1,64 @@
"""
Database engine and session factory.
We use SQLAlchemy 2.x with the "declarative" ORM style. All models should
inherit from ``Base`` defined here.
Session lifecycle:
- ``get_db()`` is a FastAPI dependency that opens a session per request and
guarantees it is closed (and rolled back on error) when the request ends.
"""
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from app.config import settings
# connect_args is SQLite-specific: it allows the same connection to be used
# from multiple threads, which is required because FastAPI runs in a thread
# pool. This flag is harmless / unused for PostgreSQL.
_connect_args = (
{"check_same_thread": False}
if settings.database_url.startswith("sqlite")
else {}
)
engine = create_engine(settings.database_url, connect_args=_connect_args)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
"""All ORM models inherit from this class."""
pass
def get_db():
"""
FastAPI dependency that yields a database session.
Usage::
@router.get("/things")
def list_things(db: Session = Depends(get_db)):
...
"""
db = SessionLocal()
try:
yield db
finally:
db.close()
def create_tables():
"""Create all tables that are registered on Base.metadata.
Called once at application startup (see main.py). Safe to run multiple
times SQLAlchemy will not recreate existing tables.
"""
# Importing models here ensures they are registered on Base.metadata
# before create_all() is called.
import app.models.user # noqa: F401
import app.models.item # noqa: F401
Base.metadata.create_all(bind=engine)

82
app/main.py Normal file
View File

@@ -0,0 +1,82 @@
"""
Flipventory v2 FastAPI application entry point.
What is this?
--------------
Inventory management system for small-to-medium resellers who use a
"chaotic stockpiling" approach: buy first, sort later. Items are added
quickly, placed in physical storage bins, and gradually processed through
a FOUND → LISTED → SOLD lifecycle.
Architecture overview
----------------------
app/
config.py settings (secret key, DB URL, admin e-mail)
database.py SQLAlchemy engine + session factory
models/ ORM table definitions (user, item, storage_bin)
schemas/ Pydantic request/response shapes
auth/ JWT creation & FastAPI dependencies
routers/
auth.py /auth/register, /auth/login, /auth/me
users.py /users CRUD (admin only)
items.py /bins and /items CRUD
Running locally
----------------
uvicorn app.main:app --reload
Then open http://127.0.0.1:8000/docs for the interactive Swagger UI.
"""
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.database import create_tables
from app.routers import auth, items, users
# ---------------------------------------------------------------------------
# Lifespan: run startup/shutdown logic without deprecated @app.on_event hooks
# ---------------------------------------------------------------------------
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: create DB tables if they don't exist yet.
# In a production deployment with Alembic migrations this call would be
# replaced by running `alembic upgrade head` in the CI/CD pipeline.
create_tables()
yield
# (Nothing to clean up on shutdown for now.)
# ---------------------------------------------------------------------------
# Application instance
# ---------------------------------------------------------------------------
app = FastAPI(
title="Flipventory v2",
description=(
"Inventory management for small/medium resellers. "
"Tame the chaos: add items fast, sort later, track every flip."
),
version="0.1.0",
lifespan=lifespan,
)
# ---------------------------------------------------------------------------
# Routers
# ---------------------------------------------------------------------------
app.include_router(auth.router)
app.include_router(users.router)
app.include_router(items.router)
# ---------------------------------------------------------------------------
# Health check
# ---------------------------------------------------------------------------
@app.get("/health", tags=["meta"])
def health() -> dict:
"""Simple liveness probe. Returns 200 if the server is up."""
return {"status": "ok", "version": app.version}

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

158
app/models/item.py Normal file
View File

@@ -0,0 +1,158 @@
"""
Inventory item and storage-bin models.
Domain context "chaotic stockpiling"
---------------------------------------
Small resellers often acquire items in bulk lots before they have time to
fully catalog them. Items may sit in physical boxes/bins while waiting to be
processed. This model supports that workflow:
StorageBin a physical or logical container (a shelf, a box, a room).
Each bin belongs to a user (the person responsible for it).
Item a single sellable thing. It tracks the typical resale
lifecycle:
FOUND → LISTED → SOLD → ARCHIVED
An item can optionally be placed in a StorageBin.
Both tables record who created/last-modified them and when, so we can
generate an audit trail (see issue #3 in the v1 Gitea project).
"""
import enum
from datetime import datetime
from sqlalchemy import (
DateTime,
Enum,
Float,
ForeignKey,
Integer,
String,
Text,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class ItemStatus(str, enum.Enum):
"""
Lifecycle states of an inventory item.
Using a string enum means the value stored in the DB is human-readable
('found', 'listed', …) rather than an opaque integer, which makes it
much easier to debug directly in the database file.
"""
FOUND = "found" # Acquired but not yet evaluated / photographed
LISTED = "listed" # Posted for sale somewhere (eBay, Kleinanzeigen, …)
SOLD = "sold" # Sold waiting for shipment or already shipped
ARCHIVED = "archived" # Off the market (broken, kept, donated, …)
class StorageBin(Base):
"""
A physical or logical container for items.
Customers create bins so they know where their chaotic pile actually is.
Admins can see all bins; customers only see their own.
"""
__tablename__ = "storage_bins"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
# Human-readable label: "Box 7", "Garage shelf B", etc.
name: Mapped[str] = mapped_column(String(120), nullable=False)
# Optional free-text description / location notes.
description: Mapped[str | None] = mapped_column(Text, nullable=True)
# The user who owns / is responsible for this bin.
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
# Back-reference so we can do bin.items to get everything inside.
items: Mapped[list["Item"]] = relationship(
"Item", back_populates="storage_bin", lazy="select"
)
def __repr__(self) -> str:
return f"<StorageBin id={self.id} name={self.name!r} owner={self.owner_id}>"
class Item(Base):
"""
A single inventory item.
Intentionally loose: the only required fields are title and status so
that items can be created quickly ("dump mode") and detailed later.
"""
__tablename__ = "items"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
# Short name shown in listings. Required.
title: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
# Detailed description for listings.
description: Mapped[str | None] = mapped_column(Text, nullable=True)
# Current lifecycle state. New items default to FOUND.
status: Mapped[ItemStatus] = mapped_column(
Enum(ItemStatus), nullable=False, default=ItemStatus.FOUND, index=True
)
# What we paid for this item (cost basis for profit tracking).
purchase_price: Mapped[float | None] = mapped_column(Float, nullable=True)
# What we're asking / what it sold for.
listing_price: Mapped[float | None] = mapped_column(Float, nullable=True)
sale_price: Mapped[float | None] = mapped_column(Float, nullable=True)
# Optional link to where it's listed (eBay URL, Kleinanzeigen link, …).
listing_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Optional SKU / internal reference number.
sku: Mapped[str | None] = mapped_column(
String(80), nullable=True, index=True, unique=True
)
# Which bin this item is currently sitting in. Null = "somewhere".
storage_bin_id: Mapped[int | None] = mapped_column(
ForeignKey("storage_bins.id", ondelete="SET NULL"), nullable=True, index=True
)
storage_bin: Mapped[StorageBin | None] = relationship(
"StorageBin", back_populates="items"
)
# Who created this item and who last touched it.
created_by_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
updated_by_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
def __repr__(self) -> str:
return f"<Item id={self.id} title={self.title!r} status={self.status}>"

72
app/models/user.py Normal file
View File

@@ -0,0 +1,72 @@
"""
User model.
Roles
------
There are exactly two roles:
- **admin** full access to everything: create/edit/delete any item or
user, view all storage bins, access audit logs.
- **customer** limited view: can browse listed inventory and manage their
own storage bins.
The role is stored as a simple boolean ``is_admin`` flag. The application
config defines the one e-mail address that is ALWAYS admin (see
``settings.admin_email``). On every login that address will have its flag
forced to True, so even if the database is tampered with, the admin cannot be
locked out.
"""
from datetime import datetime
from sqlalchemy import Boolean, DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class User(Base):
__tablename__ = "users"
# ------------------------------------------------------------------
# Columns
# ------------------------------------------------------------------
id: Mapped[int] = mapped_column(primary_key=True, index=True)
# E-mail is the login identifier. Must be unique across the system.
email: Mapped[str] = mapped_column(
String(254), # RFC 5321 max e-mail length
unique=True,
index=True,
nullable=False,
)
# Display name shown in the UI. Defaults to the e-mail local part if
# not set during registration.
display_name: Mapped[str] = mapped_column(String(100), nullable=False)
# bcrypt hash of the password. We never store plaintext.
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
# True = admin, False = customer. The business rule that
# settings.admin_email is always admin is enforced in the auth layer,
# not here.
is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Soft-disable an account without deleting it. A disabled account cannot
# log in.
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# Audit timestamps set automatically by the database.
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
def __repr__(self) -> str:
role = "admin" if self.is_admin else "customer"
return f"<User id={self.id} email={self.email!r} role={role}>"

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

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

298
app/routers/items.py Normal file
View File

@@ -0,0 +1,298 @@
"""
Inventory item and storage-bin endpoints.
Storage bins
-------------
POST /bins create a bin (any authenticated user)
GET /bins list bins (admin: all bins; customer: own bins only)
GET /bins/{id} get one bin
PATCH /bins/{id} update bin (owner or admin)
DELETE /bins/{id} delete bin (owner or admin)
Items
------
POST /items create an item (any authenticated user)
GET /items list items with filters (admin: all; customer: own)
GET /items/{id} get one item
PATCH /items/{id} update item (creator or admin)
DELETE /items/{id} delete item (admin only destructive action)
Access control philosophy
--------------------------
Customers can manage their own bins and the items they created. Admins can
see and edit everything. This keeps the app useful for small teams where
everyone is adding their own pile of stuff.
"""
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.auth.security import get_current_user, require_admin
from app.database import get_db
from app.models.item import Item, ItemStatus, StorageBin
from app.models.user import User
from app.schemas.item import (
ItemCreate,
ItemListResponse,
ItemRead,
ItemUpdate,
StorageBinCreate,
StorageBinRead,
StorageBinUpdate,
)
router = APIRouter(tags=["inventory"])
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_bin_or_404(bin_id: int, db: Session) -> StorageBin:
obj = db.query(StorageBin).filter(StorageBin.id == bin_id).first()
if obj is None:
raise HTTPException(status_code=404, detail=f"Storage bin {bin_id} not found")
return obj
def _get_item_or_404(item_id: int, db: Session) -> Item:
obj = db.query(Item).filter(Item.id == item_id).first()
if obj is None:
raise HTTPException(status_code=404, detail=f"Item {item_id} not found")
return obj
def _assert_bin_access(bin: StorageBin, user: User) -> None:
"""Raise 403 if ``user`` is not allowed to modify ``bin``."""
if not user.is_admin and bin.owner_id != user.id:
raise HTTPException(status_code=403, detail="Not your storage bin")
def _assert_item_write_access(item: Item, user: User) -> None:
"""Raise 403 if ``user`` is not allowed to modify ``item``."""
if not user.is_admin and item.created_by_id != user.id:
raise HTTPException(status_code=403, detail="Not your item")
# ---------------------------------------------------------------------------
# Storage Bin endpoints
# ---------------------------------------------------------------------------
@router.post("/bins", response_model=StorageBinRead, status_code=201, tags=["bins"])
def create_bin(
body: StorageBinCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> StorageBin:
"""
Create a new storage bin owned by the currently logged-in user.
Any authenticated user can create bins for their own pile.
"""
new_bin = StorageBin(
name=body.name,
description=body.description,
owner_id=current_user.id,
)
db.add(new_bin)
db.commit()
db.refresh(new_bin)
return new_bin
@router.get("/bins", response_model=list[StorageBinRead], tags=["bins"])
def list_bins(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list[StorageBin]:
"""
List storage bins.
Admins see every bin. Customers see only their own.
"""
q = db.query(StorageBin)
if not current_user.is_admin:
q = q.filter(StorageBin.owner_id == current_user.id)
return q.order_by(StorageBin.created_at.desc()).all()
@router.get("/bins/{bin_id}", response_model=StorageBinRead, tags=["bins"])
def get_bin(
bin_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> StorageBin:
"""Return one storage bin. Customers can only see their own."""
obj = _get_bin_or_404(bin_id, db)
if not current_user.is_admin and obj.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not your storage bin")
return obj
@router.patch("/bins/{bin_id}", response_model=StorageBinRead, tags=["bins"])
def update_bin(
bin_id: int,
body: StorageBinUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> StorageBin:
"""Update a storage bin's name or description."""
obj = _get_bin_or_404(bin_id, db)
_assert_bin_access(obj, current_user)
for field, value in body.model_dump(exclude_unset=True).items():
setattr(obj, field, value)
db.commit()
db.refresh(obj)
return obj
@router.delete("/bins/{bin_id}", status_code=204, tags=["bins"])
def delete_bin(
bin_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> None:
"""
Delete a storage bin.
Items inside the bin are NOT deleted their storage_bin_id is set to
NULL by the FK ``ON DELETE SET NULL`` rule, so they become "un-binned".
"""
obj = _get_bin_or_404(bin_id, db)
_assert_bin_access(obj, current_user)
db.delete(obj)
db.commit()
# ---------------------------------------------------------------------------
# Item endpoints
# ---------------------------------------------------------------------------
@router.post("/items", response_model=ItemRead, status_code=201, tags=["items"])
def create_item(
body: ItemCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> Item:
"""
Add a new inventory item.
The creating user is recorded as ``created_by_id``. Only ``title`` is
required all other fields default to None / FOUND so items can be
entered quickly during a sorting session.
"""
# Validate that the referenced bin exists and belongs to the caller.
if body.storage_bin_id is not None:
the_bin = db.query(StorageBin).filter(
StorageBin.id == body.storage_bin_id
).first()
if the_bin is None:
raise HTTPException(status_code=404, detail="Storage bin not found")
if not current_user.is_admin and the_bin.owner_id != current_user.id:
raise HTTPException(
status_code=403, detail="Cannot place item in someone else's bin"
)
item = Item(
**body.model_dump(),
created_by_id=current_user.id,
updated_by_id=current_user.id,
)
db.add(item)
db.commit()
db.refresh(item)
return item
@router.get("/items", response_model=ItemListResponse, tags=["items"])
def list_items(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
page: int = Query(1, ge=1, description="Page number (1-indexed)"),
page_size: int = Query(20, ge=1, le=100, description="Items per page"),
status_filter: ItemStatus | None = Query(
None, alias="status", description="Filter by lifecycle status"
),
search: str | None = Query(None, description="Full-text search on title"),
) -> dict:
"""
List inventory items with optional filtering and pagination.
- Admins see all items.
- Customers see only items they created.
- Filter by ?status=found|listed|sold|archived
- Filter by ?search=keyword (case-insensitive title match)
"""
q = db.query(Item)
if not current_user.is_admin:
q = q.filter(Item.created_by_id == current_user.id)
if status_filter is not None:
q = q.filter(Item.status == status_filter)
if search:
q = q.filter(Item.title.ilike(f"%{search}%"))
total = q.count()
offset = (page - 1) * page_size
items = q.order_by(Item.created_at.desc()).offset(offset).limit(page_size).all()
return {"total": total, "page": page, "page_size": page_size, "items": items}
@router.get("/items/{item_id}", response_model=ItemRead, tags=["items"])
def get_item(
item_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> Item:
"""Return a single item. Customers can only see items they created."""
item = _get_item_or_404(item_id, db)
if not current_user.is_admin and item.created_by_id != current_user.id:
raise HTTPException(status_code=403, detail="Not your item")
return item
@router.patch("/items/{item_id}", response_model=ItemRead, tags=["items"])
def update_item(
item_id: int,
body: ItemUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> Item:
"""
Update item fields.
Typical use-cases:
- Change status from FOUND → LISTED after photographing
- Add listing_url after posting on eBay
- Record sale_price and flip to SOLD after it sells
"""
item = _get_item_or_404(item_id, db)
_assert_item_write_access(item, current_user)
for field, value in body.model_dump(exclude_unset=True).items():
setattr(item, field, value)
item.updated_by_id = current_user.id
db.commit()
db.refresh(item)
return item
@router.delete("/items/{item_id}", status_code=204, tags=["items"])
def delete_item(
item_id: int,
db: Session = Depends(get_db),
_admin: User = Depends(require_admin),
) -> None:
"""
Hard-delete an item. Admin only.
Customers should use PATCH to set status=ARCHIVED instead. This endpoint
exists for admins to clean up test data or genuine data-entry mistakes.
"""
item = _get_item_or_404(item_id, db)
db.delete(item)
db.commit()

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()

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

11
requirements.txt Normal file
View File

@@ -0,0 +1,11 @@
fastapi==0.115.5
uvicorn[standard]==0.32.1
sqlalchemy==2.0.36
alembic==1.14.0
pydantic==2.13.4
pydantic-settings==2.6.1
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.18
httpx==0.28.0
jinja2==3.1.4