From 4e0d0d396276e7e1d95fd8a829da7e6364c76b5f Mon Sep 17 00:00:00 2001 From: Martin Hohenberg Date: Tue, 16 Jun 2026 10:40:53 +0200 Subject: [PATCH] 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 --- .env.example | 18 +++ .gitignore | 27 ++++ app/__init__.py | 0 app/auth/__init__.py | 0 app/auth/security.py | 139 +++++++++++++++++++ app/config.py | 45 ++++++ app/database.py | 64 +++++++++ app/main.py | 82 +++++++++++ app/models/__init__.py | 0 app/models/item.py | 158 +++++++++++++++++++++ app/models/user.py | 72 ++++++++++ app/routers/__init__.py | 0 app/routers/auth.py | 113 +++++++++++++++ app/routers/items.py | 298 ++++++++++++++++++++++++++++++++++++++++ app/routers/users.py | 126 +++++++++++++++++ app/schemas/__init__.py | 0 app/schemas/item.py | 112 +++++++++++++++ app/schemas/user.py | 67 +++++++++ requirements.txt | 11 ++ 19 files changed, 1332 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 app/__init__.py create mode 100644 app/auth/__init__.py create mode 100644 app/auth/security.py create mode 100644 app/config.py create mode 100644 app/database.py create mode 100644 app/main.py create mode 100644 app/models/__init__.py create mode 100644 app/models/item.py create mode 100644 app/models/user.py create mode 100644 app/routers/__init__.py create mode 100644 app/routers/auth.py create mode 100644 app/routers/items.py create mode 100644 app/routers/users.py create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/item.py create mode 100644 app/schemas/user.py create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f57103d --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..06432ee --- /dev/null +++ b/.gitignore @@ -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 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth/__init__.py b/app/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth/security.py b/app/auth/security.py new file mode 100644 index 0000000..7d329f1 --- /dev/null +++ b/app/auth/security.py @@ -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 `` +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 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..b3527c8 --- /dev/null +++ b/app/config.py @@ -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() diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..3b2e1b4 --- /dev/null +++ b/app/database.py @@ -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) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..82a3924 --- /dev/null +++ b/app/main.py @@ -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} diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/item.py b/app/models/item.py new file mode 100644 index 0000000..bf5c163 --- /dev/null +++ b/app/models/item.py @@ -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"" + + +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"" diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..dee4caf --- /dev/null +++ b/app/models/user.py @@ -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"" diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..af8fed5 --- /dev/null +++ b/app/routers/auth.py @@ -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 `` + 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 diff --git a/app/routers/items.py b/app/routers/items.py new file mode 100644 index 0000000..ce7e553 --- /dev/null +++ b/app/routers/items.py @@ -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() diff --git a/app/routers/users.py b/app/routers/users.py new file mode 100644 index 0000000..a8b3fd2 --- /dev/null +++ b/app/routers/users.py @@ -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() diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/schemas/item.py b/app/schemas/item.py new file mode 100644 index 0000000..994f512 --- /dev/null +++ b/app/schemas/item.py @@ -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] diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..573e520 --- /dev/null +++ b/app/schemas/user.py @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c62c800 --- /dev/null +++ b/requirements.txt @@ -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