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/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}>"