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>
159 lines
5.4 KiB
Python
159 lines
5.4 KiB
Python
"""
|
||
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}>"
|