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:
18
.env.example
Normal file
18
.env.example
Normal file
@@ -0,0 +1,18 @@
|
||||
# Copy this to .env and fill in real values.
|
||||
# Never commit .env to version control.
|
||||
|
||||
# Secret key for signing JWT tokens – generate with: openssl rand -hex 32
|
||||
SECRET_KEY=change-me-generate-with-openssl-rand-hex-32
|
||||
|
||||
# JWT algorithm (HS256 is fine for single-server setups)
|
||||
ALGORITHM=HS256
|
||||
|
||||
# How many minutes a login token stays valid
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||
|
||||
# SQLite path relative to the project root (or a full postgresql:// URL)
|
||||
DATABASE_URL=sqlite:///./flipventory.db
|
||||
|
||||
# The email address that is always treated as the site-wide admin.
|
||||
# This user gets admin=True forced on every login, regardless of DB state.
|
||||
ADMIN_EMAIL=martin@hohenberg.jp
|
||||
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
# Python bytecode
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# Environment / secrets
|
||||
.env
|
||||
|
||||
# SQLite database (runtime artefact – not source)
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# Editor / OS noise
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
|
||||
# Alembic generated migration artefacts that should not be committed carelessly
|
||||
# (comment this out once you start using Alembic for real)
|
||||
# alembic/versions/*.py
|
||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
0
app/auth/__init__.py
Normal file
0
app/auth/__init__.py
Normal file
139
app/auth/security.py
Normal file
139
app/auth/security.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
Authentication helpers: password hashing and JWT token management.
|
||||
|
||||
Password hashing
|
||||
-----------------
|
||||
We use passlib's bcrypt scheme. bcrypt is intentionally slow (tunable cost
|
||||
factor) which makes brute-force attacks against the hash database expensive.
|
||||
Never store or log plaintext passwords.
|
||||
|
||||
JWT tokens
|
||||
-----------
|
||||
Tokens are signed with HS256 (HMAC-SHA256). The payload contains:
|
||||
- ``sub`` – the user's e-mail address (the "subject")
|
||||
- ``exp`` – expiry timestamp (unix epoch seconds)
|
||||
|
||||
The token is passed by the client in the ``Authorization: Bearer <token>``
|
||||
header. FastAPI's OAuth2PasswordBearer extracts the raw token string for us.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Password helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# CryptContext manages the algorithm list. If we ever want to upgrade (e.g.,
|
||||
# argon2) we can add it to the list and passlib will migrate hashes on login.
|
||||
_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def hash_password(plaintext: str) -> str:
|
||||
"""Return the bcrypt hash of ``plaintext``."""
|
||||
return _pwd_context.hash(plaintext)
|
||||
|
||||
|
||||
def verify_password(plaintext: str, hashed: str) -> bool:
|
||||
"""Return True if ``plaintext`` matches ``hashed``."""
|
||||
return _pwd_context.verify(plaintext, hashed)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JWT helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def create_access_token(subject: str) -> str:
|
||||
"""
|
||||
Create a signed JWT token for ``subject`` (the user's e-mail).
|
||||
|
||||
The token expires after ``settings.access_token_expire_minutes`` minutes
|
||||
from the time of creation.
|
||||
"""
|
||||
expire = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=settings.access_token_expire_minutes
|
||||
)
|
||||
payload = {"sub": subject, "exp": expire}
|
||||
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
|
||||
|
||||
|
||||
def _decode_token(token: str) -> str:
|
||||
"""
|
||||
Decode and validate a JWT token.
|
||||
|
||||
Returns the ``sub`` claim (e-mail) on success.
|
||||
Raises HTTPException(401) if the token is invalid or expired.
|
||||
"""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token, settings.secret_key, algorithms=[settings.algorithm]
|
||||
)
|
||||
email: str | None = payload.get("sub")
|
||||
if email is None:
|
||||
raise credentials_exception
|
||||
return email
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FastAPI dependencies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# tokenUrl must match the path of our login endpoint so Swagger UI's
|
||||
# "Authorize" button works.
|
||||
_oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
|
||||
|
||||
|
||||
def get_current_user(
|
||||
token: str = Depends(_oauth2_scheme),
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
"""
|
||||
Dependency: resolve a Bearer token to a User ORM object.
|
||||
|
||||
Raises 401 if the token is invalid/expired.
|
||||
Raises 403 if the account is disabled.
|
||||
"""
|
||||
email = _decode_token(token)
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Account is disabled",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def require_admin(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""
|
||||
Dependency: ensure the logged-in user is an admin.
|
||||
|
||||
Raises 403 if they are not. Use this on any endpoint that should be
|
||||
admin-only.
|
||||
"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin access required",
|
||||
)
|
||||
return current_user
|
||||
45
app/config.py
Normal file
45
app/config.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
Application-wide settings loaded from environment variables (or a .env file).
|
||||
|
||||
pydantic-settings reads the .env file automatically when BaseSettings is used,
|
||||
so there is no need to call load_dotenv() manually.
|
||||
"""
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# -------------------------------------------------------------------------
|
||||
# Auth / Security
|
||||
# -------------------------------------------------------------------------
|
||||
# Signing secret for JWT tokens. Must be kept private on the server.
|
||||
secret_key: str = "insecure-dev-secret-replace-in-production"
|
||||
|
||||
# JWT signing algorithm. HS256 is a symmetric HMAC-SHA256 scheme which
|
||||
# is perfectly fine for a single-server application.
|
||||
algorithm: str = "HS256"
|
||||
|
||||
# Token lifetime in minutes. Tokens expire after this period and the
|
||||
# user must log in again.
|
||||
access_token_expire_minutes: int = 60
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Database
|
||||
# -------------------------------------------------------------------------
|
||||
# Any SQLAlchemy-compatible URL. Defaults to a local SQLite file so the
|
||||
# app works out-of-the-box without extra infrastructure.
|
||||
database_url: str = "sqlite:///./flipventory.db"
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Business rules
|
||||
# -------------------------------------------------------------------------
|
||||
# The e-mail address that is ALWAYS treated as the site-wide administrator.
|
||||
# Even if the database row for this user somehow has admin=False, every
|
||||
# login will force-set it back to True.
|
||||
admin_email: str = "martin@hohenberg.jp"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
# Module-level singleton – import this everywhere instead of re-instantiating.
|
||||
settings = Settings()
|
||||
64
app/database.py
Normal file
64
app/database.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
Database engine and session factory.
|
||||
|
||||
We use SQLAlchemy 2.x with the "declarative" ORM style. All models should
|
||||
inherit from ``Base`` defined here.
|
||||
|
||||
Session lifecycle:
|
||||
- ``get_db()`` is a FastAPI dependency that opens a session per request and
|
||||
guarantees it is closed (and rolled back on error) when the request ends.
|
||||
"""
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
# connect_args is SQLite-specific: it allows the same connection to be used
|
||||
# from multiple threads, which is required because FastAPI runs in a thread
|
||||
# pool. This flag is harmless / unused for PostgreSQL.
|
||||
_connect_args = (
|
||||
{"check_same_thread": False}
|
||||
if settings.database_url.startswith("sqlite")
|
||||
else {}
|
||||
)
|
||||
|
||||
engine = create_engine(settings.database_url, connect_args=_connect_args)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""All ORM models inherit from this class."""
|
||||
pass
|
||||
|
||||
|
||||
def get_db():
|
||||
"""
|
||||
FastAPI dependency that yields a database session.
|
||||
|
||||
Usage::
|
||||
|
||||
@router.get("/things")
|
||||
def list_things(db: Session = Depends(get_db)):
|
||||
...
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def create_tables():
|
||||
"""Create all tables that are registered on Base.metadata.
|
||||
|
||||
Called once at application startup (see main.py). Safe to run multiple
|
||||
times – SQLAlchemy will not recreate existing tables.
|
||||
"""
|
||||
# Importing models here ensures they are registered on Base.metadata
|
||||
# before create_all() is called.
|
||||
import app.models.user # noqa: F401
|
||||
import app.models.item # noqa: F401
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
82
app/main.py
Normal file
82
app/main.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
Flipventory v2 – FastAPI application entry point.
|
||||
|
||||
What is this?
|
||||
--------------
|
||||
Inventory management system for small-to-medium resellers who use a
|
||||
"chaotic stockpiling" approach: buy first, sort later. Items are added
|
||||
quickly, placed in physical storage bins, and gradually processed through
|
||||
a FOUND → LISTED → SOLD lifecycle.
|
||||
|
||||
Architecture overview
|
||||
----------------------
|
||||
app/
|
||||
config.py – settings (secret key, DB URL, admin e-mail)
|
||||
database.py – SQLAlchemy engine + session factory
|
||||
models/ – ORM table definitions (user, item, storage_bin)
|
||||
schemas/ – Pydantic request/response shapes
|
||||
auth/ – JWT creation & FastAPI dependencies
|
||||
routers/
|
||||
auth.py – /auth/register, /auth/login, /auth/me
|
||||
users.py – /users CRUD (admin only)
|
||||
items.py – /bins and /items CRUD
|
||||
|
||||
Running locally
|
||||
----------------
|
||||
uvicorn app.main:app --reload
|
||||
|
||||
Then open http://127.0.0.1:8000/docs for the interactive Swagger UI.
|
||||
"""
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.database import create_tables
|
||||
from app.routers import auth, items, users
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifespan: run startup/shutdown logic without deprecated @app.on_event hooks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup: create DB tables if they don't exist yet.
|
||||
# In a production deployment with Alembic migrations this call would be
|
||||
# replaced by running `alembic upgrade head` in the CI/CD pipeline.
|
||||
create_tables()
|
||||
yield
|
||||
# (Nothing to clean up on shutdown for now.)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Application instance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
app = FastAPI(
|
||||
title="Flipventory v2",
|
||||
description=(
|
||||
"Inventory management for small/medium resellers. "
|
||||
"Tame the chaos: add items fast, sort later, track every flip."
|
||||
),
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(items.router)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Health check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/health", tags=["meta"])
|
||||
def health() -> dict:
|
||||
"""Simple liveness probe. Returns 200 if the server is up."""
|
||||
return {"status": "ok", "version": app.version}
|
||||
0
app/models/__init__.py
Normal file
0
app/models/__init__.py
Normal file
158
app/models/item.py
Normal file
158
app/models/item.py
Normal 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
72
app/models/user.py
Normal 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}>"
|
||||
0
app/routers/__init__.py
Normal file
0
app/routers/__init__.py
Normal file
113
app/routers/auth.py
Normal file
113
app/routers/auth.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
Authentication endpoints.
|
||||
|
||||
POST /auth/register – create a new account (always customer, unless the
|
||||
registered e-mail matches settings.admin_email)
|
||||
POST /auth/login – exchange credentials for a JWT Bearer token
|
||||
GET /auth/me – return the currently logged-in user's profile
|
||||
|
||||
Login flow
|
||||
-----------
|
||||
1. Client POSTs e-mail + password as form data (OAuth2 compatible).
|
||||
2. We look up the user, verify the password, and issue a JWT.
|
||||
3. The admin e-mail guard: if this is the hardcoded admin address, we force
|
||||
is_admin=True in the DB on every login. This prevents accidental lockout.
|
||||
4. Client stores the token and sends it as ``Authorization: Bearer <token>``
|
||||
on subsequent requests.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth.security import (
|
||||
create_access_token,
|
||||
get_current_user,
|
||||
hash_password,
|
||||
verify_password,
|
||||
)
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.user import TokenResponse, UserCreate, UserRead
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserRead, status_code=status.HTTP_201_CREATED)
|
||||
def register(body: UserCreate, db: Session = Depends(get_db)) -> User:
|
||||
"""
|
||||
Register a new user account.
|
||||
|
||||
- E-mail must be unique.
|
||||
- If the e-mail matches the configured admin address the account is
|
||||
immediately given admin rights (regardless of what the caller sends).
|
||||
- All other accounts are customers (is_admin=False).
|
||||
"""
|
||||
# Reject duplicate e-mails early with a clear error.
|
||||
existing = db.query(User).filter(User.email == body.email).first()
|
||||
if existing is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="An account with this e-mail already exists",
|
||||
)
|
||||
|
||||
# The hardcoded admin e-mail is always an admin.
|
||||
is_admin = body.email.lower() == settings.admin_email.lower()
|
||||
|
||||
user = User(
|
||||
email=body.email.lower(),
|
||||
display_name=body.display_name,
|
||||
hashed_password=hash_password(body.password),
|
||||
is_admin=is_admin,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""
|
||||
Log in and receive a JWT Bearer token.
|
||||
|
||||
Uses the standard OAuth2 ``application/x-www-form-urlencoded`` format so
|
||||
that Swagger UI's "Authorize" button works without extra setup.
|
||||
|
||||
Fields: ``username`` (= e-mail), ``password``
|
||||
"""
|
||||
# username field holds the e-mail (OAuth2 standard naming)
|
||||
user = db.query(User).filter(User.email == form_data.username.lower()).first()
|
||||
|
||||
if user is None or not verify_password(form_data.password, user.hashed_password):
|
||||
# Return the same generic message for both "user not found" and
|
||||
# "wrong password" to avoid leaking which e-mail addresses exist.
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect e-mail or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Account is disabled – contact the administrator",
|
||||
)
|
||||
|
||||
# Enforce the "admin e-mail is always admin" rule on every login.
|
||||
if user.email == settings.admin_email.lower() and not user.is_admin:
|
||||
user.is_admin = True
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
token = create_access_token(subject=user.email)
|
||||
return {"access_token": token, "token_type": "bearer", "user": user}
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserRead)
|
||||
def get_me(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""Return the profile of the currently authenticated user."""
|
||||
return current_user
|
||||
298
app/routers/items.py
Normal file
298
app/routers/items.py
Normal file
@@ -0,0 +1,298 @@
|
||||
"""
|
||||
Inventory item and storage-bin endpoints.
|
||||
|
||||
Storage bins
|
||||
-------------
|
||||
POST /bins – create a bin (any authenticated user)
|
||||
GET /bins – list bins (admin: all bins; customer: own bins only)
|
||||
GET /bins/{id} – get one bin
|
||||
PATCH /bins/{id} – update bin (owner or admin)
|
||||
DELETE /bins/{id} – delete bin (owner or admin)
|
||||
|
||||
Items
|
||||
------
|
||||
POST /items – create an item (any authenticated user)
|
||||
GET /items – list items with filters (admin: all; customer: own)
|
||||
GET /items/{id} – get one item
|
||||
PATCH /items/{id} – update item (creator or admin)
|
||||
DELETE /items/{id} – delete item (admin only – destructive action)
|
||||
|
||||
Access control philosophy
|
||||
--------------------------
|
||||
Customers can manage their own bins and the items they created. Admins can
|
||||
see and edit everything. This keeps the app useful for small teams where
|
||||
everyone is adding their own pile of stuff.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth.security import get_current_user, require_admin
|
||||
from app.database import get_db
|
||||
from app.models.item import Item, ItemStatus, StorageBin
|
||||
from app.models.user import User
|
||||
from app.schemas.item import (
|
||||
ItemCreate,
|
||||
ItemListResponse,
|
||||
ItemRead,
|
||||
ItemUpdate,
|
||||
StorageBinCreate,
|
||||
StorageBinRead,
|
||||
StorageBinUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["inventory"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_bin_or_404(bin_id: int, db: Session) -> StorageBin:
|
||||
obj = db.query(StorageBin).filter(StorageBin.id == bin_id).first()
|
||||
if obj is None:
|
||||
raise HTTPException(status_code=404, detail=f"Storage bin {bin_id} not found")
|
||||
return obj
|
||||
|
||||
|
||||
def _get_item_or_404(item_id: int, db: Session) -> Item:
|
||||
obj = db.query(Item).filter(Item.id == item_id).first()
|
||||
if obj is None:
|
||||
raise HTTPException(status_code=404, detail=f"Item {item_id} not found")
|
||||
return obj
|
||||
|
||||
|
||||
def _assert_bin_access(bin: StorageBin, user: User) -> None:
|
||||
"""Raise 403 if ``user`` is not allowed to modify ``bin``."""
|
||||
if not user.is_admin and bin.owner_id != user.id:
|
||||
raise HTTPException(status_code=403, detail="Not your storage bin")
|
||||
|
||||
|
||||
def _assert_item_write_access(item: Item, user: User) -> None:
|
||||
"""Raise 403 if ``user`` is not allowed to modify ``item``."""
|
||||
if not user.is_admin and item.created_by_id != user.id:
|
||||
raise HTTPException(status_code=403, detail="Not your item")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Storage Bin endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/bins", response_model=StorageBinRead, status_code=201, tags=["bins"])
|
||||
def create_bin(
|
||||
body: StorageBinCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> StorageBin:
|
||||
"""
|
||||
Create a new storage bin owned by the currently logged-in user.
|
||||
|
||||
Any authenticated user can create bins for their own pile.
|
||||
"""
|
||||
new_bin = StorageBin(
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
owner_id=current_user.id,
|
||||
)
|
||||
db.add(new_bin)
|
||||
db.commit()
|
||||
db.refresh(new_bin)
|
||||
return new_bin
|
||||
|
||||
|
||||
@router.get("/bins", response_model=list[StorageBinRead], tags=["bins"])
|
||||
def list_bins(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> list[StorageBin]:
|
||||
"""
|
||||
List storage bins.
|
||||
|
||||
Admins see every bin. Customers see only their own.
|
||||
"""
|
||||
q = db.query(StorageBin)
|
||||
if not current_user.is_admin:
|
||||
q = q.filter(StorageBin.owner_id == current_user.id)
|
||||
return q.order_by(StorageBin.created_at.desc()).all()
|
||||
|
||||
|
||||
@router.get("/bins/{bin_id}", response_model=StorageBinRead, tags=["bins"])
|
||||
def get_bin(
|
||||
bin_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> StorageBin:
|
||||
"""Return one storage bin. Customers can only see their own."""
|
||||
obj = _get_bin_or_404(bin_id, db)
|
||||
if not current_user.is_admin and obj.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not your storage bin")
|
||||
return obj
|
||||
|
||||
|
||||
@router.patch("/bins/{bin_id}", response_model=StorageBinRead, tags=["bins"])
|
||||
def update_bin(
|
||||
bin_id: int,
|
||||
body: StorageBinUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> StorageBin:
|
||||
"""Update a storage bin's name or description."""
|
||||
obj = _get_bin_or_404(bin_id, db)
|
||||
_assert_bin_access(obj, current_user)
|
||||
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(obj, field, value)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
@router.delete("/bins/{bin_id}", status_code=204, tags=["bins"])
|
||||
def delete_bin(
|
||||
bin_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> None:
|
||||
"""
|
||||
Delete a storage bin.
|
||||
|
||||
Items inside the bin are NOT deleted – their storage_bin_id is set to
|
||||
NULL by the FK ``ON DELETE SET NULL`` rule, so they become "un-binned".
|
||||
"""
|
||||
obj = _get_bin_or_404(bin_id, db)
|
||||
_assert_bin_access(obj, current_user)
|
||||
db.delete(obj)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Item endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/items", response_model=ItemRead, status_code=201, tags=["items"])
|
||||
def create_item(
|
||||
body: ItemCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Item:
|
||||
"""
|
||||
Add a new inventory item.
|
||||
|
||||
The creating user is recorded as ``created_by_id``. Only ``title`` is
|
||||
required – all other fields default to None / FOUND so items can be
|
||||
entered quickly during a sorting session.
|
||||
"""
|
||||
# Validate that the referenced bin exists and belongs to the caller.
|
||||
if body.storage_bin_id is not None:
|
||||
the_bin = db.query(StorageBin).filter(
|
||||
StorageBin.id == body.storage_bin_id
|
||||
).first()
|
||||
if the_bin is None:
|
||||
raise HTTPException(status_code=404, detail="Storage bin not found")
|
||||
if not current_user.is_admin and the_bin.owner_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Cannot place item in someone else's bin"
|
||||
)
|
||||
|
||||
item = Item(
|
||||
**body.model_dump(),
|
||||
created_by_id=current_user.id,
|
||||
updated_by_id=current_user.id,
|
||||
)
|
||||
db.add(item)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
@router.get("/items", response_model=ItemListResponse, tags=["items"])
|
||||
def list_items(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
page: int = Query(1, ge=1, description="Page number (1-indexed)"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="Items per page"),
|
||||
status_filter: ItemStatus | None = Query(
|
||||
None, alias="status", description="Filter by lifecycle status"
|
||||
),
|
||||
search: str | None = Query(None, description="Full-text search on title"),
|
||||
) -> dict:
|
||||
"""
|
||||
List inventory items with optional filtering and pagination.
|
||||
|
||||
- Admins see all items.
|
||||
- Customers see only items they created.
|
||||
- Filter by ?status=found|listed|sold|archived
|
||||
- Filter by ?search=keyword (case-insensitive title match)
|
||||
"""
|
||||
q = db.query(Item)
|
||||
|
||||
if not current_user.is_admin:
|
||||
q = q.filter(Item.created_by_id == current_user.id)
|
||||
|
||||
if status_filter is not None:
|
||||
q = q.filter(Item.status == status_filter)
|
||||
|
||||
if search:
|
||||
q = q.filter(Item.title.ilike(f"%{search}%"))
|
||||
|
||||
total = q.count()
|
||||
offset = (page - 1) * page_size
|
||||
items = q.order_by(Item.created_at.desc()).offset(offset).limit(page_size).all()
|
||||
|
||||
return {"total": total, "page": page, "page_size": page_size, "items": items}
|
||||
|
||||
|
||||
@router.get("/items/{item_id}", response_model=ItemRead, tags=["items"])
|
||||
def get_item(
|
||||
item_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Item:
|
||||
"""Return a single item. Customers can only see items they created."""
|
||||
item = _get_item_or_404(item_id, db)
|
||||
if not current_user.is_admin and item.created_by_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not your item")
|
||||
return item
|
||||
|
||||
|
||||
@router.patch("/items/{item_id}", response_model=ItemRead, tags=["items"])
|
||||
def update_item(
|
||||
item_id: int,
|
||||
body: ItemUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Item:
|
||||
"""
|
||||
Update item fields.
|
||||
|
||||
Typical use-cases:
|
||||
- Change status from FOUND → LISTED after photographing
|
||||
- Add listing_url after posting on eBay
|
||||
- Record sale_price and flip to SOLD after it sells
|
||||
"""
|
||||
item = _get_item_or_404(item_id, db)
|
||||
_assert_item_write_access(item, current_user)
|
||||
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(item, field, value)
|
||||
|
||||
item.updated_by_id = current_user.id
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
@router.delete("/items/{item_id}", status_code=204, tags=["items"])
|
||||
def delete_item(
|
||||
item_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_admin: User = Depends(require_admin),
|
||||
) -> None:
|
||||
"""
|
||||
Hard-delete an item. Admin only.
|
||||
|
||||
Customers should use PATCH to set status=ARCHIVED instead. This endpoint
|
||||
exists for admins to clean up test data or genuine data-entry mistakes.
|
||||
"""
|
||||
item = _get_item_or_404(item_id, db)
|
||||
db.delete(item)
|
||||
db.commit()
|
||||
126
app/routers/users.py
Normal file
126
app/routers/users.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""
|
||||
User management endpoints (admin only).
|
||||
|
||||
GET /users – list all users
|
||||
GET /users/{id} – get one user
|
||||
PATCH /users/{id} – update display name, active status, or admin flag
|
||||
DELETE /users/{id} – soft-delete (deactivate) a user
|
||||
|
||||
All endpoints require admin role. Self-deactivation / self-demotion is
|
||||
blocked to prevent admins from accidentally locking themselves out.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth.security import require_admin
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserRead, UserUpdate
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
def _get_user_or_404(user_id: int, db: Session) -> User:
|
||||
"""Fetch a user by PK or raise 404."""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User {user_id} not found",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@router.get("", response_model=list[UserRead])
|
||||
def list_users(
|
||||
db: Session = Depends(get_db),
|
||||
_admin: User = Depends(require_admin),
|
||||
) -> list[User]:
|
||||
"""Return all registered users. Admin only."""
|
||||
return db.query(User).order_by(User.created_at.desc()).all()
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserRead)
|
||||
def get_user(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_admin: User = Depends(require_admin),
|
||||
) -> User:
|
||||
"""Return a single user by ID. Admin only."""
|
||||
return _get_user_or_404(user_id, db)
|
||||
|
||||
|
||||
@router.patch("/{user_id}", response_model=UserRead)
|
||||
def update_user(
|
||||
user_id: int,
|
||||
body: UserUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(require_admin),
|
||||
) -> User:
|
||||
"""
|
||||
Update a user's display name, active state, or admin flag.
|
||||
|
||||
Guards:
|
||||
- Cannot demote the permanent admin e-mail address.
|
||||
- Cannot deactivate your own account (you'd be locked out).
|
||||
"""
|
||||
user = _get_user_or_404(user_id, db)
|
||||
|
||||
# Block demotion of the hardcoded admin.
|
||||
if (
|
||||
user.email == settings.admin_email.lower()
|
||||
and body.is_admin is not None
|
||||
and body.is_admin is False
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot demote the permanent admin account",
|
||||
)
|
||||
|
||||
# Block self-deactivation.
|
||||
if user.id == admin.id and body.is_active is False:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot deactivate your own account",
|
||||
)
|
||||
|
||||
# Apply updates – only fields explicitly provided in the request body.
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(user, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def deactivate_user(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(require_admin),
|
||||
) -> None:
|
||||
"""
|
||||
Soft-delete a user by setting is_active=False.
|
||||
|
||||
We do not hard-delete because items reference users via created_by_id /
|
||||
updated_by_id and we need the audit trail to remain intact.
|
||||
|
||||
Cannot deactivate the permanent admin or yourself.
|
||||
"""
|
||||
user = _get_user_or_404(user_id, db)
|
||||
|
||||
if user.email == settings.admin_email.lower():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot deactivate the permanent admin account",
|
||||
)
|
||||
if user.id == admin.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot deactivate your own account",
|
||||
)
|
||||
|
||||
user.is_active = False
|
||||
db.commit()
|
||||
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
|
||||
11
requirements.txt
Normal file
11
requirements.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
fastapi==0.115.5
|
||||
uvicorn[standard]==0.32.1
|
||||
sqlalchemy==2.0.36
|
||||
alembic==1.14.0
|
||||
pydantic==2.13.4
|
||||
pydantic-settings==2.6.1
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-multipart==0.0.18
|
||||
httpx==0.28.0
|
||||
jinja2==3.1.4
|
||||
Reference in New Issue
Block a user