From 1ac5a191bedcf67d5f5c707949047e27e511491c Mon Sep 17 00:00:00 2001 From: derekc Date: Tue, 24 Mar 2026 21:55:32 -0700 Subject: [PATCH] Update README with current features, API, and data model Co-Authored-By: Claude Sonnet 4.6 --- README.md | 148 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 109 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index b6d2c98..af0ce04 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,18 @@ # 🥃 Bourbonacci -A personal bourbon tracking web app. Log pours, track your collection, and view stats across users. Built with FastAPI, vanilla JS, and MySQL — fully containerized with Docker Compose. +A self-hosted infinity bottle tracker. Log every bourbon you add to your blend, track what you've consumed, and watch your bottle's story unfold over time. Community bottles are visible to anyone who visits the site — click a card to browse the full bourbon list inside that bottle. + +Built with FastAPI, vanilla JS, and MySQL — fully containerized with Docker Compose. No framework dependencies on the frontend. + +## Features + +- **Infinity bottle tracking** — log `add` entries (bourbon name, proof, shots) and `remove` entries (shots consumed) +- **Live stats** — bourbons added, total shots poured in, shots remaining, estimated weighted proof +- **Bottle fill bar** — visual level indicator based on your configured bottle size +- **Community page** — public leaderboard of all bottles; click any card to see a searchable, alphabetical bourbon list +- **Settings modal** — update display name, bottle size, timezone, and password from any page via the gear icon +- **Admin panel** — list all users, reset passwords, disable/enable accounts, delete users, and impersonate any user for debugging +- **JWT auth** — Bearer token auth with impersonation support (admin tokens carry an `admin_id` claim) ## Stack @@ -9,8 +21,8 @@ A personal bourbon tracking web app. Log pours, track your collection, and view | Backend | FastAPI (Python 3.12), async SQLAlchemy 2.0, Uvicorn | | Database | MySQL 8 | | Frontend | Vanilla HTML/CSS/JS (no framework) | -| Reverse Proxy | Nginx (Alpine) | -| Auth | JWT via `python-jose`, passwords hashed with `passlib[bcrypt]` | +| Reverse Proxy | Nginx | +| Auth | JWT via `python-jose`, passwords hashed with `passlib[bcrypt==4.0.1]` | | Container | Docker Compose | ## Project Structure @@ -19,55 +31,82 @@ A personal bourbon tracking web app. Log pours, track your collection, and view bourbonacci/ ├── backend/ │ ├── app/ -│ │ ├── main.py # FastAPI app entry point, lifespan (DB init) -│ │ ├── config.py # Pydantic settings -│ │ ├── database.py # Async engine, session factory, Base -│ │ ├── dependencies.py # get_db, get_current_user +│ │ ├── main.py # FastAPI app entry point, lifespan (DB init + admin seed) +│ │ ├── config.py # Pydantic settings (reads from .env) +│ │ ├── database.py # Async engine, session factory, Base, init_db() +│ │ ├── dependencies.py # get_db, get_current_user, get_current_admin │ │ ├── models/ │ │ │ ├── user.py # User ORM model │ │ │ └── entry.py # Entry ORM model (add/remove types) │ │ ├── routers/ │ │ │ ├── auth.py # POST /api/auth/register, /api/auth/login -│ │ │ ├── users.py # GET/PATCH /api/users/me +│ │ │ ├── users.py # GET/PUT /api/users/me, PUT /api/users/me/password │ │ │ ├── entries.py # CRUD /api/entries, GET /api/entries/stats -│ │ │ └── public.py # GET /api/public/stats (unauthenticated) +│ │ │ ├── public.py # GET /api/public/stats (unauthenticated) +│ │ │ └── admin.py # Admin user management + impersonation │ │ ├── schemas/ │ │ │ ├── user.py # Pydantic schemas for user/auth │ │ │ └── entry.py # Pydantic schemas for entries/stats │ │ └── utils/ -│ │ └── security.py # JWT creation, password hashing/verification +│ │ └── security.py # JWT creation/decode, password hashing │ ├── Dockerfile │ └── requirements.txt ├── frontend/ -│ ├── index.html # Landing / public stats page +│ ├── index.html # Landing page + public community bottles │ ├── login.html │ ├── register.html -│ ├── dashboard.html # Authenticated home with stats -│ ├── log.html # Log a new entry -│ ├── profile.html # User profile / settings -│ ├── css/style.css +│ ├── dashboard.html # My Bottle — stats, fill bar, entry log +│ ├── log.html # Log add or remove entries +│ ├── profile.html # Profile settings (display name, timezone, password) +│ ├── admin.html # Admin user management panel +│ ├── css/style.css # Bourbon-themed dark UI │ └── js/ -│ ├── api.js # Fetch wrapper, base URL, token handling -│ └── auth.js # Login/register/logout logic +│ ├── api.js # Fetch wrapper, token injection, escHtml, admin API +│ ├── auth.js # Auth state, nav rendering, settings modal +│ └── admin.js # Admin page logic (user table, modals, impersonation) ├── nginx/ │ └── default.conf # Serves frontend, proxies /api/ to backend:8000 ├── docker-compose.yml └── .env.example ``` +## Pages + +| Page | Auth | Description | +|---|---|---| +| `/index.html` | No | Landing page with community bottle cards; click to browse bourbons | +| `/login.html` | No | Login form | +| `/register.html` | No | Registration form | +| `/dashboard.html` | Yes | My Bottle — stats, fill bar, full entry log with delete | +| `/log.html` | Yes | Log an add or remove entry | +| `/profile.html` | Yes | Edit display name, timezone, bottle size, password | +| `/admin.html` | Admin | User management — reset PW, disable, impersonate, delete | + +## Navigation + +The top-right nav area shows (when logged in): + +- **Display name** — non-clickable label +- **Admin** button — only visible to admin accounts, links to `/admin.html` +- **⚙ Settings** button — opens a modal to update display name, bottle size, timezone, and password +- **Logout** button + +When impersonating a user, the nav shows **"Viewing as [name]"** and a **Return to Admin** button. + ## API Endpoints ### Auth | Method | Path | Auth | Description | |---|---|---|---| -| POST | `/api/auth/register` | No | Register, returns JWT | +| POST | `/api/auth/register` | No | Register new account, returns JWT | | POST | `/api/auth/login` | No | Login, returns JWT | ### Users | Method | Path | Auth | Description | |---|---|---|---| | GET | `/api/users/me` | Yes | Get current user profile | -| PATCH | `/api/users/me` | Yes | Update display name / timezone | +| PUT | `/api/users/me` | Yes | Update display name, timezone, bottle size | +| PUT | `/api/users/me/password` | Yes | Change password | ### Entries | Method | Path | Auth | Description | @@ -80,7 +119,7 @@ bourbonacci/ ### Public | Method | Path | Auth | Description | |---|---|---|---| -| GET | `/api/public/stats` | No | Aggregated stats for all users | +| GET | `/api/public/stats` | No | Stats + bourbon list for all users | ### Admin | Method | Path | Auth | Description | @@ -90,9 +129,9 @@ bourbonacci/ | POST | `/api/admin/users/{id}/reset-password` | Admin | Force-reset a user's password | | POST | `/api/admin/users/{id}/disable` | Admin | Disable a user account | | POST | `/api/admin/users/{id}/enable` | Admin | Re-enable a user account | -| DELETE | `/api/admin/users/{id}` | Admin | Hard-delete a user | +| DELETE | `/api/admin/users/{id}` | Admin | Hard-delete a user and all their data | | POST | `/api/admin/users/{id}/impersonate` | Admin | Get a token scoped as that user | -| POST | `/api/admin/unimpersonate` | Admin | Swap back to the admin token | +| POST | `/api/admin/unimpersonate` | Admin | Exchange impersonation token back for admin token | Authenticated routes expect `Authorization: Bearer ` header. @@ -109,7 +148,18 @@ Authenticated routes expect `Authorization: Bearer ` header. } ``` -Stats return `current_total_shots` (adds minus removes) and `estimated_proof` (weighted average across add entries). +### Stats Response + +```json +{ + "total_add_entries": 12, + "total_shots_added": 18.5, + "current_total_shots": 14.25, + "estimated_proof": 95.3 +} +``` + +`current_total_shots` = total added − total removed. `estimated_proof` = weighted average proof across all add entries by shot count. ## Getting Started @@ -126,7 +176,7 @@ cp .env.example .env Generate a secure secret key: ```bash -python3 -c "import secrets; print(secrets.token_hex(32))" +python3 -c "import secrets; print(secrets.token_hex(64))" ``` ### Run @@ -135,9 +185,9 @@ python3 -c "import secrets; print(secrets.token_hex(32))" docker compose up -d ``` -The app will be available at `http://localhost:8057`. +The app will be available at `http://localhost` (or whichever port is mapped in `docker-compose.yml`). -The backend waits for MySQL to pass its healthcheck before starting. Tables are created automatically on first boot via SQLAlchemy's `init_db()`. +The backend waits for MySQL to pass its healthcheck before starting. Tables are created automatically on first boot via SQLAlchemy's `create_all`. The admin account defined in `.env` is seeded/re-synced on every container start. ### Stop @@ -149,22 +199,42 @@ docker compose down -v ## Environment Variables -| Variable | Description | Default | -|---|---|---| -| `MYSQL_ROOT_PASSWORD` | MySQL root password | — | -| `MYSQL_DATABASE` | Database name | `bourbonacci` | -| `MYSQL_USER` | App DB user | `bourbonacci` | -| `MYSQL_PASSWORD` | App DB password | — | -| `DATABASE_URL` | SQLAlchemy async DSN | `mysql+aiomysql://...` | -| `SECRET_KEY` | JWT signing secret (keep long & random) | — | -| `ACCESS_TOKEN_EXPIRE_MINUTES` | JWT TTL in minutes | `480` | -| `ADMIN_USERNAME` | Admin account email (seeded on every start) | — | -| `ADMIN_PASSWORD` | Admin account password (re-synced on every start) | — | +| Variable | Description | +|---|---| +| `MYSQL_ROOT_PASSWORD` | MySQL root password | +| `MYSQL_DATABASE` | Database name (default: `bourbonacci`) | +| `MYSQL_USER` | App DB user | +| `MYSQL_PASSWORD` | App DB password | +| `DATABASE_URL` | SQLAlchemy async DSN (`mysql+aiomysql://user:pass@db:3306/dbname`) | +| `SECRET_KEY` | JWT signing secret — keep long and random | +| `ACCESS_TOKEN_EXPIRE_MINUTES` | JWT TTL in minutes (default: `480`) | +| `ADMIN_USERNAME` | Admin account email, seeded on every container start | +| `ADMIN_PASSWORD` | Admin account password, re-synced on every container start | ## Data Model **users** -- `id`, `email` (unique), `password_hash`, `display_name`, `timezone`, `created_at` +| Column | Type | Notes | +|---|---|---| +| `id` | int PK | | +| `email` | varchar(255) unique | | +| `password_hash` | varchar(255) | bcrypt | +| `display_name` | varchar(100) nullable | | +| `timezone` | varchar(50) | default `UTC` | +| `bottle_size` | float nullable | user-configured bottle capacity in shots | +| `is_admin` | bool | default false | +| `is_disabled` | bool | default false | +| `created_at` | datetime | server default | **entries** -- `id`, `user_id` (FK), `entry_type` (`add`/`remove`), `date`, `bourbon_name`, `proof`, `amount_shots`, `notes`, `created_at` +| Column | Type | Notes | +|---|---|---| +| `id` | int PK | | +| `user_id` | int FK → users | cascade delete | +| `entry_type` | enum | `add` or `remove` | +| `date` | date | user-supplied | +| `bourbon_name` | varchar nullable | required for `add` entries | +| `proof` | float nullable | | +| `amount_shots` | float | default 1.0 | +| `notes` | text nullable | | +| `created_at` | datetime | server default |