Update README with current features, API, and data model
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
148
README.md
148
README.md
@@ -1,6 +1,18 @@
|
|||||||
# 🥃 Bourbonacci
|
# 🥃 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
|
## 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 |
|
| Backend | FastAPI (Python 3.12), async SQLAlchemy 2.0, Uvicorn |
|
||||||
| Database | MySQL 8 |
|
| Database | MySQL 8 |
|
||||||
| Frontend | Vanilla HTML/CSS/JS (no framework) |
|
| Frontend | Vanilla HTML/CSS/JS (no framework) |
|
||||||
| Reverse Proxy | Nginx (Alpine) |
|
| Reverse Proxy | Nginx |
|
||||||
| Auth | JWT via `python-jose`, passwords hashed with `passlib[bcrypt]` |
|
| Auth | JWT via `python-jose`, passwords hashed with `passlib[bcrypt==4.0.1]` |
|
||||||
| Container | Docker Compose |
|
| Container | Docker Compose |
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
@@ -19,55 +31,82 @@ A personal bourbon tracking web app. Log pours, track your collection, and view
|
|||||||
bourbonacci/
|
bourbonacci/
|
||||||
├── backend/
|
├── backend/
|
||||||
│ ├── app/
|
│ ├── app/
|
||||||
│ │ ├── main.py # FastAPI app entry point, lifespan (DB init)
|
│ │ ├── main.py # FastAPI app entry point, lifespan (DB init + admin seed)
|
||||||
│ │ ├── config.py # Pydantic settings
|
│ │ ├── config.py # Pydantic settings (reads from .env)
|
||||||
│ │ ├── database.py # Async engine, session factory, Base
|
│ │ ├── database.py # Async engine, session factory, Base, init_db()
|
||||||
│ │ ├── dependencies.py # get_db, get_current_user
|
│ │ ├── dependencies.py # get_db, get_current_user, get_current_admin
|
||||||
│ │ ├── models/
|
│ │ ├── models/
|
||||||
│ │ │ ├── user.py # User ORM model
|
│ │ │ ├── user.py # User ORM model
|
||||||
│ │ │ └── entry.py # Entry ORM model (add/remove types)
|
│ │ │ └── entry.py # Entry ORM model (add/remove types)
|
||||||
│ │ ├── routers/
|
│ │ ├── routers/
|
||||||
│ │ │ ├── auth.py # POST /api/auth/register, /api/auth/login
|
│ │ │ ├── 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
|
│ │ │ ├── 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/
|
│ │ ├── schemas/
|
||||||
│ │ │ ├── user.py # Pydantic schemas for user/auth
|
│ │ │ ├── user.py # Pydantic schemas for user/auth
|
||||||
│ │ │ └── entry.py # Pydantic schemas for entries/stats
|
│ │ │ └── entry.py # Pydantic schemas for entries/stats
|
||||||
│ │ └── utils/
|
│ │ └── utils/
|
||||||
│ │ └── security.py # JWT creation, password hashing/verification
|
│ │ └── security.py # JWT creation/decode, password hashing
|
||||||
│ ├── Dockerfile
|
│ ├── Dockerfile
|
||||||
│ └── requirements.txt
|
│ └── requirements.txt
|
||||||
├── frontend/
|
├── frontend/
|
||||||
│ ├── index.html # Landing / public stats page
|
│ ├── index.html # Landing page + public community bottles
|
||||||
│ ├── login.html
|
│ ├── login.html
|
||||||
│ ├── register.html
|
│ ├── register.html
|
||||||
│ ├── dashboard.html # Authenticated home with stats
|
│ ├── dashboard.html # My Bottle — stats, fill bar, entry log
|
||||||
│ ├── log.html # Log a new entry
|
│ ├── log.html # Log add or remove entries
|
||||||
│ ├── profile.html # User profile / settings
|
│ ├── profile.html # Profile settings (display name, timezone, password)
|
||||||
│ ├── css/style.css
|
│ ├── admin.html # Admin user management panel
|
||||||
|
│ ├── css/style.css # Bourbon-themed dark UI
|
||||||
│ └── js/
|
│ └── js/
|
||||||
│ ├── api.js # Fetch wrapper, base URL, token handling
|
│ ├── api.js # Fetch wrapper, token injection, escHtml, admin API
|
||||||
│ └── auth.js # Login/register/logout logic
|
│ ├── auth.js # Auth state, nav rendering, settings modal
|
||||||
|
│ └── admin.js # Admin page logic (user table, modals, impersonation)
|
||||||
├── nginx/
|
├── nginx/
|
||||||
│ └── default.conf # Serves frontend, proxies /api/ to backend:8000
|
│ └── default.conf # Serves frontend, proxies /api/ to backend:8000
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
└── .env.example
|
└── .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
|
## API Endpoints
|
||||||
|
|
||||||
### Auth
|
### Auth
|
||||||
| Method | Path | Auth | Description |
|
| 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 |
|
| POST | `/api/auth/login` | No | Login, returns JWT |
|
||||||
|
|
||||||
### Users
|
### Users
|
||||||
| Method | Path | Auth | Description |
|
| Method | Path | Auth | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| GET | `/api/users/me` | Yes | Get current user profile |
|
| 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
|
### Entries
|
||||||
| Method | Path | Auth | Description |
|
| Method | Path | Auth | Description |
|
||||||
@@ -80,7 +119,7 @@ bourbonacci/
|
|||||||
### Public
|
### Public
|
||||||
| Method | Path | Auth | Description |
|
| 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
|
### Admin
|
||||||
| Method | Path | Auth | Description |
|
| 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}/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}/disable` | Admin | Disable a user account |
|
||||||
| POST | `/api/admin/users/{id}/enable` | Admin | Re-enable 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/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 <token>` header.
|
Authenticated routes expect `Authorization: Bearer <token>` header.
|
||||||
|
|
||||||
@@ -109,7 +148,18 @@ Authenticated routes expect `Authorization: Bearer <token>` 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
|
## Getting Started
|
||||||
|
|
||||||
@@ -126,7 +176,7 @@ cp .env.example .env
|
|||||||
|
|
||||||
Generate a secure secret key:
|
Generate a secure secret key:
|
||||||
```bash
|
```bash
|
||||||
python3 -c "import secrets; print(secrets.token_hex(32))"
|
python3 -c "import secrets; print(secrets.token_hex(64))"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Run
|
### Run
|
||||||
@@ -135,9 +185,9 @@ python3 -c "import secrets; print(secrets.token_hex(32))"
|
|||||||
docker compose up -d
|
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
|
### Stop
|
||||||
|
|
||||||
@@ -149,22 +199,42 @@ docker compose down -v
|
|||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description |
|
||||||
|---|---|---|
|
|---|---|
|
||||||
| `MYSQL_ROOT_PASSWORD` | MySQL root password | — |
|
| `MYSQL_ROOT_PASSWORD` | MySQL root password |
|
||||||
| `MYSQL_DATABASE` | Database name | `bourbonacci` |
|
| `MYSQL_DATABASE` | Database name (default: `bourbonacci`) |
|
||||||
| `MYSQL_USER` | App DB user | `bourbonacci` |
|
| `MYSQL_USER` | App DB user |
|
||||||
| `MYSQL_PASSWORD` | App DB password | — |
|
| `MYSQL_PASSWORD` | App DB password |
|
||||||
| `DATABASE_URL` | SQLAlchemy async DSN | `mysql+aiomysql://...` |
|
| `DATABASE_URL` | SQLAlchemy async DSN (`mysql+aiomysql://user:pass@db:3306/dbname`) |
|
||||||
| `SECRET_KEY` | JWT signing secret (keep long & random) | — |
|
| `SECRET_KEY` | JWT signing secret — keep long and random |
|
||||||
| `ACCESS_TOKEN_EXPIRE_MINUTES` | JWT TTL in minutes | `480` |
|
| `ACCESS_TOKEN_EXPIRE_MINUTES` | JWT TTL in minutes (default: `480`) |
|
||||||
| `ADMIN_USERNAME` | Admin account email (seeded on every start) | — |
|
| `ADMIN_USERNAME` | Admin account email, seeded on every container start |
|
||||||
| `ADMIN_PASSWORD` | Admin account password (re-synced on every start) | — |
|
| `ADMIN_PASSWORD` | Admin account password, re-synced on every container start |
|
||||||
|
|
||||||
## Data Model
|
## Data Model
|
||||||
|
|
||||||
**users**
|
**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**
|
**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 |
|
||||||
|
|||||||
Reference in New Issue
Block a user