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:
0
app/schemas/__init__.py
Normal file
0
app/schemas/__init__.py
Normal file
112
app/schemas/item.py
Normal file
112
app/schemas/item.py
Normal 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
67
app/schemas/user.py
Normal 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
|
||||
Reference in New Issue
Block a user