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