Files
flipventory_v2/app/models/item.py
Martin Hohenberg 4e0d0d3962 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>
2026-06-16 10:40:53 +02:00

159 lines
5.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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}>"