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

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