docs: update comments and README to reflect current feature set

- Refresh app.py module and route docstrings for runtime settings,
  MP3 tagging, rate limiting, revision workflow, and admin actions.
- Clarify config.py and models.py comments.
- Update template comments/CSS for dashboard filters, request detail,
  player page, and settings page.
- Rewrite README.md with current features, status flow, file layout,
  deployment variables, and troubleshooting.
- Refresh REVIEW.md quick-reference.
- Add MAX_REVISIONS to docker-compose.yml environment list.
- Expand requirements.txt comment coverage.

No version history or changelog included.
This commit is contained in:
Troll (Hermes Agent) 2026-08-02 00:07:24 +00:00
parent 14ed80fe4b
commit 4af9322c9d
11 changed files with 148 additions and 111 deletions

View file

@ -1,16 +1,37 @@
# Theme Song Booth # Theme Song Booth
Custom theme-song request and delivery system for a convention booth. Customers fill out a form, the operator generates two AI-made song versions, the customer picks one, and the approved MP3 is delivered by email after payment is collected. A Flask web app for a convention booth where visitors request a custom AI-generated theme song, the operator manages the queue, and the final MP3(s) are delivered by email after payment.
## What this project does ## Features
- **Customer request page** (`/request`) — booth visitors enter their name, email, hobbies, notable facts, preferred genre, and extra requests. A branded banner image is shown. ### Customer-facing
- **Operator dashboard** (`/admin`) — queue of all requests with status filters, per-request detail page, and system reset.
- **Prompt generation** — the admin page builds a plain-text prompt for Hermes/AI, which returns a Title, Style, and Lyrics block. The operator pastes that response, clicks **Extract**, then uses Copy buttons to paste into Suno Custom Mode. - **Request form** (`/request`) — visitors enter name, email, hobbies, notable facts, preferred style/genre, vocal gender preference, and extra requests. A branded banner image is shown.
- **Song upload** — operator uploads Version A and Version B MP3s. - **Confirmation page** (`/thanks/<id>`) — shows the request number after submission.
- **Customer player page** — a private `/play/<token>` page emails to the customer. They can listen to both versions, choose A/B/both, or request changes. - **Private player page** (`/play/<token>`) — customer receives an email with a unique link. They can stream Version A and Version B, pick one (or both), or request a limited number of revisions.
- **Payment and delivery** — operator enters a Square payment reference and clicks **Mark Paid & Deliver**. The approved MP3(s) are emailed as attachments. - **Revision workflow** — when a customer asks for changes, the current MP3s are archived and the operator sees the request as "Revisions Requested" in the dashboard.
- **System reset** — one button in the admin topbar clears all requests and files at the start of an event. - **Rate limiting** — the public request form is capped at 5 submissions per minute per IP.
### Operator/admin
- **Admin login** (`/admin/login`) — simple session-based login protected by `ADMIN_PASSWORD`.
- **Dashboard queue** (`/admin`) — filter by status (All, Pending, Needs Upload, Awaiting Payment, Delivered) and auto-refresh at a configurable interval.
- **Per-request detail page** (`/admin/request/<id>`):
- Generate and save a Suno prompt from customer info.
- Upload Version A and Version B MP3s (with automatic ID3 metadata tagging).
- Send a preview email with a private player link.
- Mark paid, enter a Square payment reference, and deliver selected MP3 attachments.
- **Settings / maintenance page** (`/admin/settings`):
- Database health check with optional schema repair.
- Database statistics, size, upload counts.
- Configure customer revision limit.
- Configure dashboard auto-refresh interval.
- Configure SMTP host/port/user/from; store the SMTP password encrypted.
- Configure default MP3 metadata tags (artist, album, year, comment).
- Send a test email.
- Download or restore the SQLite database backup.
- Reset the entire system for a new event.
- **Per-request delete** and **system reset** — remove requests and uploaded files; reset auto-increment back to 1.
## Status flow ## Status flow
@ -20,33 +41,36 @@ pending → prompt_ready → songs_uploaded → awaiting_payment → paid → de
| Status | Meaning | | Status | Meaning |
|---|---| |---|---|
| `pending` | Customer submitted request; no prompt yet. | | `pending` | Customer submitted a request; operator has not saved a prompt yet. |
| `prompt_ready` | Operator saved Title/Style/Lyrics. | | `prompt_ready` | Operator saved Title/Style/Lyrics. |
| `songs_uploaded` | Both MP3s uploaded; preview link can be sent. | | `songs_uploaded` | Both MP3s uploaded; preview link can be sent. Dashboard filter label: **Needs Upload** (shown for this state when filtering). |
| `awaiting_payment` | Customer approved a version. | | `revisions_requested` | Customer asked for changes; current files were archived. |
| `paid` | Payment reference recorded; delivery email sent. | | `awaiting_payment` | Customer approved a version; waiting for operator to collect payment and deliver. |
| `delivered` | MP3 attachments emailed. | | `paid` | Payment recorded (used internally). |
| `delivered` | MP3 attachment(s) emailed to the customer. |
## File layout ## File layout
| File | Purpose | | File | Purpose |
|---|---| |---|---|
| `app.py` | Flask routes, helpers, and email logic. | | `app.py` | Flask routes, helpers, email layer, runtime settings, MP3 tagging, rate limiting, and DB maintenance helpers. |
| `config.py` | Environment-variable based configuration. | | `config.py` | Environment-variable based configuration; defines defaults for DB, uploads, SMTP, and secrets. |
| `models.py` | SQLite schema and database helper functions. | | `models.py` | SQLite schema and CRUD helpers. |
| `init_db.py` | Standalone script to create the database tables. | | `init_db.py` | Standalone script to create the database tables. |
| `templates/request.html` | Customer request form (with banner). | | `templates/request.html` | Customer request form. |
| `templates/thanks.html` | Post-submission confirmation. | | `templates/thanks.html` | Post-submission confirmation. |
| `templates/player.html` | Customer audio player and approval page. | | `templates/player.html` | Customer audio player, approval, and revision form. |
| `templates/admin/login.html` | Admin password login. | | `templates/admin/login.html` | Admin login page. |
| `templates/admin/dashboard.html` | Operator queue with filters and reset. | | `templates/admin/dashboard.html` | Operator queue with filters and auto-refresh. |
| `templates/admin/request.html` | Single-request detail / prompt / upload / delivery. | | `templates/admin/request.html` | Single-request detail / prompt / upload / delivery page. |
| `templates/admin/settings.html` | Maintenance, settings, backup/restore, and reset page. |
| `static/Trollgorithm_booth.jpg` | Banner image on the request page. | | `static/Trollgorithm_booth.jpg` | Banner image on the request page. |
| `static/DM-Logo_email.png` | Inline Dionysis Media logo attached to emails. |
| `Dockerfile` | Production container image. | | `Dockerfile` | Production container image. |
| `docker-compose.yml` | Portainer stack definition. | | `docker-compose.yml` | Portainer stack definition. |
| `requirements.txt` | Python dependencies. | | `requirements.txt` | Python dependencies. |
| `.env.example` | Template for environment variables. | | `.env.example` | Template for local environment variables. |
| `REVIEW.md` | Quick reference for returning to this project. | | `REVIEW.md` | Quick-reference for returning to this project. |
## Local development ## Local development
@ -76,18 +100,19 @@ Visit:
| Variable | Required | Purpose | | Variable | Required | Purpose |
|---|---|---| |---|---|---|
| `APP_SECRET_KEY` | Yes | Long random string for Flask sessions. Generate with `python3 -c "import secrets; print(secrets.token_hex(32))"`. | | `APP_SECRET_KEY` | Yes | Long random string for Flask sessions and to encrypt stored SMTP password. Generate with `python3 -c "import secrets; print(secrets.token_hex(32))"`. |
| `ADMIN_PASSWORD` | Yes | Password for `/admin`. | | `ADMIN_PASSWORD` | Yes | Password for `/admin`. |
| `SMTP_PASS` | Yes | Password for `ai@hallsworth.ca`. | | `SMTP_PASS` | Yes | Password for the SMTP account. |
| `PUBLIC_BASE_URL` | Yes | Public HTTPS URL, e.g. `https://booth.dionysismedia.ca`. | | `PUBLIC_BASE_URL` | Yes | Public HTTPS URL, e.g. `https://booth.dionysismedia.ca`. |
| `HOST_PORT` | No | Host-side port mapping, default `127.0.0.1:8000`. | | `HOST_PORT` | No | Host-side port mapping, default `127.0.0.1:8000`. |
| `INTERNAL_PORT` | No | Port gunicorn binds inside container, default `8000`. | | `INTERNAL_PORT` | No | Port gunicorn binds inside container, default `8000`. |
| `BOOTH_NAME` | No | Name used in emails, default `Trollgorithm Theme Songs`. | | `BOOTH_NAME` | No | Name used in emails, default `Trollgorithm Theme Songs`. |
| `PRICE_PER_VERSION` | No | Shown on receipt page, default `10.00`. | | `PRICE_PER_VERSION` | No | Shown to the operator/customer, default `10.00`. |
| `CURRENCY` | No | Currency label, default `CAD`. | | `CURRENCY` | No | Currency label, default `CAD`. |
| `MAX_REVISIONS` | No | Default customer revision limit if not changed in settings, default `2`. |
5. Deploy the stack. 5. Deploy the stack.
6. Open a console in the `booth` container and run once: 6. Open a console in the `theme-song-booth` container and run once:
```bash ```bash
python init_db.py python init_db.py
@ -103,8 +128,11 @@ After each push to GitLab, go to Portainer → **Stacks** → `theme-song-booth`
## Important notes ## Important notes
- **No `.env` file in production.** `docker-compose.yml` passes variables directly from Portainer. This avoids Portainer's `env_file not found` error. - **No `.env` file in production.** `docker-compose.yml` passes variables directly from Portainer. This avoids Portainer's `env_file not found` error.
- **Runtime settings persist.** SMTP config, revision limit, auto-refresh interval, and MP3 metadata defaults are stored encrypted (where sensitive) in `booth_settings.json` inside the persistent uploads volume. They survive redeploys.
- **Payments are manual.** The app records a Square payment reference but does not integrate with Square's API. Use a Square Terminal/Reader at the booth. - **Payments are manual.** The app records a Square payment reference but does not integrate with Square's API. Use a Square Terminal/Reader at the booth.
- **Operator queue is the dashboard.** No operator email alerts are sent; approvals and revision notes appear as status changes in `/admin`. - **Operator queue is the dashboard.** No operator email alerts are sent; approvals and revision notes appear as status changes in `/admin`.
- **MP3 metadata.** Uploaded files are tagged with title (from the saved prompt), plus configured artist/album/year/comment values.
- **Email logo.** `static/DM-Logo_email.png` is attached inline to all customer emails as the Dionysis Media signature.
- **Security:** the repo is public on GitLab. No secrets are committed. Admin password is plain text in the Portainer environment. - **Security:** the repo is public on GitLab. No secrets are committed. Admin password is plain text in the Portainer environment.
## Common troubleshooting ## Common troubleshooting
@ -112,9 +140,11 @@ After each push to GitLab, go to Portainer → **Stacks** → `theme-song-booth`
| Problem | Cause | Fix | | Problem | Cause | Fix |
|---|---|---| |---|---|---|
| "Send Preview Link" does nothing | Form tags were unbalanced (now fixed). | Redeploy the latest commit. | | "Send Preview Link" does nothing | Form tags were unbalanced (now fixed). | Redeploy the latest commit. |
| Emails not arriving | SMTP_PASS wrong or messages in spam. | Verify SMTP credentials; check spam folder. | | Emails not arriving | SMTP settings wrong or messages in spam. | Use **Send Test Email** on `/admin/settings`; verify host/port/password. |
| Can't reach app through domain | Reverse proxy points to wrong host port. | Match `HOST_PORT` to your proxy upstream. | | Can't reach app through domain | Reverse proxy points to wrong host port. | Match `HOST_PORT` to your proxy upstream. |
| Static banner not showing | Browser cached old image. | Hard-refresh or redeploy stack. | | Static banner not showing | Browser cached old image. | Hard-refresh or redeploy stack. |
| Logo missing from email | Logo file missing from `static/`. | Ensure `static/DM-Logo_email.png` is in the container. |
| Database schema mismatch | New column added but old DB not migrated. | Go to `/admin/settings` and click **Fix Missing Columns**, or run `python init_db.py`. |
## License / ownership ## License / ownership

View file

@ -14,6 +14,9 @@ Flask app that lets convention attendees request custom AI-generated theme songs
- Portainer stack deployed from GitLab repo - Portainer stack deployed from GitLab repo
- SMTP (SSL port 465) for customer emails - SMTP (SSL port 465) for customer emails
- Square Terminal/Reader for manual payment - Square Terminal/Reader for manual payment
- `mutagen` for MP3 metadata tagging
- `flask-limiter` for public form rate limiting
- `cryptography` to encrypt the stored SMTP password
## Repository ## Repository
@ -24,12 +27,13 @@ Flask app that lets convention attendees request custom AI-generated theme songs
| File | Notes | | File | Notes |
|---|---| |---|---|
| `app.py` | All routes, helpers, email function, status labels. | | `app.py` | All routes, helpers, email function, status labels, runtime settings, MP3 tagging, rate limiting, DB health. |
| `config.py` | Env vars. `ADMIN_PASSWORD` is plain text. | | `config.py` | Env vars. `ADMIN_PASSWORD` is plain text. |
| `models.py` | SQLite schema + CRUD. `player_token` is a secret URL-safe token. | | `models.py` | SQLite schema + CRUD. `player_token` is a secret URL-safe token. |
| `init_db.py` | Run once after deploy: `python init_db.py`. | | `init_db.py` | Run once after deploy: `python init_db.py`. |
| `templates/admin/request.html` | Biggest template; prompt extraction JS lives here. | | `templates/admin/request.html` | Biggest template; prompt copy helpers and JS live here. |
| `templates/admin/dashboard.html` | Queue table + topbar Reset System button. | | `templates/admin/dashboard.html` | Queue table + filters + auto-refresh + topbar Reset System button. |
| `templates/admin/settings.html` | SMTP config, MP3 metadata defaults, DB backup/restore, health check, reset. |
| `docker-compose.yml` | No `env_file`; variables come from Portainer. | | `docker-compose.yml` | No `env_file`; variables come from Portainer. |
## Status meanings ## Status meanings
@ -38,12 +42,14 @@ Flask app that lets convention attendees request custom AI-generated theme songs
pending → prompt_ready → songs_uploaded → awaiting_payment → paid → delivered pending → prompt_ready → songs_uploaded → awaiting_payment → paid → delivered
``` ```
`revisions_requested` is a branch used when the customer asks for changes.
## Operator workflow ## Operator workflow
1. Customer fills `/request`. 1. Customer fills `/request`.
2. Open `/admin`, click request row. 2. Open `/admin`, click request row (or filter by status).
3. Click **Copy customer info for Hermes**, paste result to Hermes. 3. On `/admin/request/<id>`, click **Copy customer info for Hermes**, paste result to Hermes.
4. Paste Hermes response (Title/Style/Lyrics format), click **Extract**, click **Save Prompt**. 4. Paste Hermes response (Title/Style/Lyrics format) into the fields and click **Save Prompt**.
5. Copy Style/Lyrics into Suno Custom Mode, generate two versions. 5. Copy Style/Lyrics into Suno Custom Mode, generate two versions.
6. Upload Version A and B MP3s. 6. Upload Version A and B MP3s.
7. Click **Send Preview Link**. 7. Click **Send Preview Link**.
@ -61,34 +67,23 @@ PUBLIC_BASE_URL
BOOTH_NAME BOOTH_NAME
HOST_PORT HOST_PORT
INTERNAL_PORT INTERNAL_PORT
MAX_REVISIONS
``` ```
Most can be overridden at runtime from `/admin/settings` and stored in `booth_settings.json`.
## Gotchas ## Gotchas
- Multiple forms on `admin/request.html` must stay properly closed; nested forms break buttons. - Multiple forms on `admin/request.html` must stay properly closed; nested forms break buttons.
- `upload_songs` form needs `enctype="multipart/form-data"` and a matching `</form>`. - `upload_songs` form needs `enctype="multipart/form-data"` and a matching `</form>`.
- The dashboard uses `basename()` as a function, not a Jinja filter. - The dashboard uses `basename()` as a function, not a Jinja filter.
- Reset System deletes DB rows **and** all files under `UPLOAD_FOLDER`. - Reset System deletes DB rows **and** all files under `UPLOAD_FOLDER`, then resets `sqlite_sequence`.
- Runtime settings are stored in the persistent uploads volume (`booth_settings.json`).
## Things that could be improved later - Container cannot read host paths; all static assets used at runtime (logo, banner, favicons) must be in the repo or a mounted volume.
- Move customer info copy/paste to a direct Hermes API/webhook call.
- Add operator email alerts as an opt-in config instead of hard-disabled.
- Store admin password hashed.
- Add a receipt/pricing page for the customer.
- Upload progress indicator for large MP3s.
- Back up SQLite and uploads to S3 or similar before reset.
## How to redeploy ## How to redeploy
1. Push changes to GitLab `main`. 1. Push changes to GitLab `main`.
2. In Portainer: Stacks → `theme-song-booth` → Pull and redeploy. 2. In Portainer: Stacks → `theme-song-booth`**Pull and redeploy**.
3. If schema changed, open container console and run `python init_db.py`. 3. If schema changed, open container console and run `python init_db.py`, or use `/admin/settings`**Fix Missing Columns**.
## Last major changes
- Added banner image and styling to request page.
- Moved Reset System button to topbar next to Log out.
- Added file/email status badges on admin request page.
- Added per-request Delete and full-system Reset.
- Switched Hermes prompt workflow to plain-text Title/Style/Lyrics blocks.

59
app.py
View file

@ -3,7 +3,10 @@ app.py
====== ======
Main Flask application for the Theme Song Booth. Main Flask application for the Theme Song Booth.
This module defines all HTTP routes, helper functions, and the email layer. This module defines all HTTP routes, helper functions, the email layer,
runtime settings persistence, MP3 metadata tagging, rate limiting, and
database health/maintenance helpers.
It is meant to be served by gunicorn inside a Docker container (see Dockerfile). It is meant to be served by gunicorn inside a Docker container (see Dockerfile).
Public routes (customers): Public routes (customers):
@ -18,10 +21,10 @@ Public routes (customers):
Admin routes: Admin routes:
- /admin/login -> password login - /admin/login -> password login
- /admin/logout -> clears session - /admin/logout -> clears session
- /admin -> dashboard queue - /admin -> dashboard queue with status filters and auto-refresh
- /admin/settings -> health check, DB stats, disk usage, system reset - /admin/settings -> runtime settings, health check, DB stats, backup/restore, reset
- /admin/request/<id> -> detail/edit page for a single request - /admin/request/<id> -> detail/edit page for a single request
- /admin/request/<id>/delete -> deletes one request and its files - /admin/request/<id>/delete -> deletes one request and its uploaded files
- /admin/reset -> deletes ALL requests and ALL files - /admin/reset -> deletes ALL requests and ALL files
""" """
@ -65,7 +68,7 @@ from mutagen.easyid3 import EasyID3
app = Flask(__name__) app = Flask(__name__)
app.config.from_object(Config) app.config.from_object(Config)
# Request rate limiting: by remote IP. Defaults can be overridden via Limiter storage when configured. # Global request rate limiting by remote IP (default 60/min). The public form is further limited to 5/min.
limiter = Limiter(get_remote_address, app=app, default_limits=["60 per minute"]) limiter = Limiter(get_remote_address, app=app, default_limits=["60 per minute"])
# Ensure the SQLite connection is closed at the end of each request. # Ensure the SQLite connection is closed at the end of each request.
@ -136,8 +139,10 @@ def save_upload(request_id, file_obj, version, song_title=None):
def apply_mp3_tags(path, title=None): def apply_mp3_tags(path, title=None):
""" """
Write or overwrite common ID3 tags on an MP3 file using values from Write common ID3 tags on an uploaded MP3 using the runtime metadata defaults.
runtime booth settings. The saved Suno title is written to the Title tag. Writes title, artist, album, and date via EasyID3, plus a comment using both
a COMM frame and a TXXX:Comment frame for broad reader compatibility.
Failures are logged as a warning and do not block the upload.
""" """
cfg = load_booth_settings() cfg = load_booth_settings()
try: try:
@ -230,7 +235,8 @@ def save_booth_settings(settings):
def get_email_config(): def get_email_config():
""" """
Return the effective SMTP configuration. Return the effective SMTP configuration.
Runtime-encrypted settings from disk override env defaults. Runtime settings in booth_settings.json override environment defaults.
The SMTP password is decrypted from the encrypted value stored on disk.
""" """
cfg = load_booth_settings() cfg = load_booth_settings()
return { return {
@ -251,16 +257,6 @@ def get_refresh_seconds():
val = 10 val = 10
return val if val in (10, 20, 30) else 10 return val if val in (10, 20, 30) else 10
def save_booth_settings(settings):
"""Persist runtime settings to JSON file."""
cfg_path = Path(current_app.config['UPLOAD_FOLDER']).parent / 'booth_settings.json'
try:
cfg_path.write_text(json.dumps(settings, indent=2))
except OSError as e:
flash(f'Warning: could not save settings: {e}', 'error')
def send_email(to, subject, body, attachments=None, inline_images=None): def send_email(to, subject, body, attachments=None, inline_images=None):
"""Send an email using the configured or runtime SMTP settings.""" """Send an email using the configured or runtime SMTP settings."""
cfg = get_email_config() cfg = get_email_config()
@ -299,7 +295,7 @@ def send_email(to, subject, body, attachments=None, inline_images=None):
server.send_message(msg) server.send_message(msg)
def build_signature_images(): def build_signature_images():
"""Return inline image tuple list for the Dionysis Media logo.""" """Return inline image tuple list for static/DM-Logo_email.png (Dionysis Media logo)."""
logo_path = Path(current_app.root_path) / 'static' / 'DM-Logo_email.png' logo_path = Path(current_app.root_path) / 'static' / 'DM-Logo_email.png'
if not logo_path.exists(): if not logo_path.exists():
return [] return []
@ -322,7 +318,9 @@ def request_form():
""" """
Public request form. Public request form.
GET -> shows the form with the banner image. GET -> shows the form with the banner image.
POST -> creates a database record and redirects to the thanks page. POST -> creates a database record, sends a confirmation email,
and redirects to the thanks page.
Rate limited to 5 submissions per minute per IP.
""" """
if request.method == 'POST': if request.method == 'POST':
rid = create_request( rid = create_request(
@ -380,6 +378,7 @@ def play(token):
""" """
Private player page for a customer. Private player page for a customer.
The token is a cryptographically random URL-safe string generated at request time. The token is a cryptographically random URL-safe string generated at request time.
Shows A/B audio players, approval buttons, or a revision note depending on status.
""" """
req = get_request_by_token(token) req = get_request_by_token(token)
if not req: if not req:
@ -398,6 +397,7 @@ def approve(token):
""" """
Customer has chosen Version A, Version B, or both. Customer has chosen Version A, Version B, or both.
Updates the request status to 'awaiting_payment' so the operator can collect payment. Updates the request status to 'awaiting_payment' so the operator can collect payment.
Operator email alerts are intentionally disabled; the dashboard is the single queue.
""" """
req = get_request_by_token(token) req = get_request_by_token(token)
if not req: if not req:
@ -409,10 +409,6 @@ def approve(token):
update_request(req['id'], customer_approved=choice, status='awaiting_payment', approval_notified_at=now_utc()) update_request(req['id'], customer_approved=choice, status='awaiting_payment', approval_notified_at=now_utc())
# NOTE: Operator email alerts are intentionally disabled. The admin dashboard is the single queue.
# alert_to = current_app.config['ADMIN_ALERT_EMAIL'] or current_app.config['SMTP_FROM']
# if alert_to: ...
flash('Thanks! Please return to the booth to finalize payment.', 'success') flash('Thanks! Please return to the booth to finalize payment.', 'success')
return redirect(url_for('play', token=token)) return redirect(url_for('play', token=token))
@ -420,8 +416,9 @@ def approve(token):
@app.route('/play/<token>/revise', methods=['POST']) @app.route('/play/<token>/revise', methods=['POST'])
def revise(token): def revise(token):
""" """
Customer asked for changes. Store the note and reset status to 'songs_uploaded' Customer asked for changes.
so the operator sees it in the dashboard queue. Enforces the runtime max revisions limit, archives the current A/B MP3 files,
stores the revision note, and resets status to 'revisions_requested'.
""" """
note = request.form.get('revision_note', '').strip() note = request.form.get('revision_note', '').strip()
req = get_request_by_token(token) req = get_request_by_token(token)
@ -506,6 +503,7 @@ def admin_dashboard():
""" """
Main operator queue. Main operator queue.
Optional ?status= filter lets operators focus on one state at a time. Optional ?status= filter lets operators focus on one state at a time.
Auto-refresh interval is controlled from /admin/settings.
""" """
redir = require_admin() redir = require_admin()
if redir: if redir:
@ -519,9 +517,10 @@ def admin_dashboard():
def admin_request(rid): def admin_request(rid):
""" """
Detail/edit page for a single request. Detail/edit page for a single request.
GET -> render the request details and editing forms. GET -> render customer info, prompt, upload status, email status, and delivery forms.
POST -> handle one of four actions: POST -> handle one of four actions:
save_prompt, upload_songs, notify_customer, mark_paid_deliver save_prompt, upload_songs, notify_customer, mark_paid_deliver
Uploaded MP3s are tagged with metadata defaults from /admin/settings.
""" """
redir = require_admin() redir = require_admin()
if redir: if redir:
@ -658,8 +657,10 @@ def admin_delete_request(rid):
def admin_settings(): def admin_settings():
""" """
Settings / maintenance page for operators. Settings / maintenance page for operators.
GET -> show database health, statistics, disk usage, and reset button. GET -> show database health, statistics, disk usage, runtime settings forms,
POST -> either run a health check/fix or reset the system. SMTP/email config, MP3 metadata defaults, backup/restore, and reset.
POST -> handle one of: fix_db, reset_system, save_max_revisions, save_metadata,
save_email_config, send_test_email, save_refresh, download_db, restore_db.
""" """
redir = require_admin() redir = require_admin()
if redir: if redir:

View file

@ -50,12 +50,12 @@ class Config:
# Public HTTPS URL used in customer emails and QR codes. # Public HTTPS URL used in customer emails and QR codes.
PUBLIC_BASE_URL = os.environ.get('PUBLIC_BASE_URL', 'http://127.0.0.1:5000') PUBLIC_BASE_URL = os.environ.get('PUBLIC_BASE_URL', 'http://127.0.0.1:5000')
# Booth name used in email sign-offs. # Booth name used in customer-facing text and email sign-offs.
BOOTH_NAME = os.environ.get('BOOTH_NAME', 'Trollgorithm Theme Songs') BOOTH_NAME = os.environ.get('BOOTH_NAME', 'Trollgorithm Theme Songs')
# Internal port gunicorn listens on inside the container. # Internal port gunicorn listens on inside the container (also exposed in Dockerfile).
INTERNAL_PORT = int(os.environ.get('INTERNAL_PORT', '8000')) INTERNAL_PORT = int(os.environ.get('INTERNAL_PORT', '8000'))
# Price per version shown on the receipt page (informational only; payment is manual). # Informational price shown to the operator/customer; actual payment is collected manually (e.g. Square).
PRICE_PER_VERSION = float(os.environ.get('PRICE_PER_VERSION', '10.00')) PRICE_PER_VERSION = float(os.environ.get('PRICE_PER_VERSION', '10.00'))
CURRENCY = os.environ.get('CURRENCY', 'CAD') CURRENCY = os.environ.get('CURRENCY', 'CAD')

View file

@ -28,6 +28,7 @@ services:
- PUBLIC_BASE_URL=${PUBLIC_BASE_URL} - PUBLIC_BASE_URL=${PUBLIC_BASE_URL}
- BOOTH_NAME=${BOOTH_NAME:-Trollgorithm Theme Songs} - BOOTH_NAME=${BOOTH_NAME:-Trollgorithm Theme Songs}
- INTERNAL_PORT=${INTERNAL_PORT:-8000} - INTERNAL_PORT=${INTERNAL_PORT:-8000}
- MAX_REVISIONS=${MAX_REVISIONS:-2}
- PRICE_PER_VERSION=${PRICE_PER_VERSION:-10.00} - PRICE_PER_VERSION=${PRICE_PER_VERSION:-10.00}
- CURRENCY=${CURRENCY:-CAD} - CURRENCY=${CURRENCY:-CAD}
- DATABASE=${DATABASE:-/app/data/booth.db} - DATABASE=${DATABASE:-/app/data/booth.db}

View file

@ -8,7 +8,8 @@ application context (`g`) is used to manage one connection per request.
Schema overview (see SCHEMA constant): Schema overview (see SCHEMA constant):
- requests table stores customer data, generated prompts, file paths, - requests table stores customer data, generated prompts, file paths,
approval state, email timestamps, payment reference, and player token. approval state, email timestamps, payment reference, player token,
vocal gender preference, revision count, and revision notes.
- Indexes on status and player_token for fast queue/lookup. - Indexes on status and player_token for fast queue/lookup.
""" """
@ -40,7 +41,7 @@ CREATE TABLE IF NOT EXISTS requests (
preview_sent_at TIMESTAMP, preview_sent_at TIMESTAMP,
delivery_sent_at TIMESTAMP, delivery_sent_at TIMESTAMP,
square_payment_ref TEXT, square_payment_ref TEXT,
admin_alert_email TEXT, admin_alert_email TEXT, -- reserved for future operator alerts; currently unused
player_token TEXT NOT NULL UNIQUE, player_token TEXT NOT NULL UNIQUE,
revision_count INTEGER DEFAULT 0, revision_count INTEGER DEFAULT 0,
revision_note TEXT revision_note TEXT
@ -115,7 +116,9 @@ def get_request_by_token(token):
def list_requests(status=None): def list_requests(status=None):
"""List all requests, optionally filtered by status, newest first.""" """List all requests, optionally filtered by status, newest first.
Normalizes whitespace in the status parameter so URLs like "Needs Upload"
match the stored value."""
db = get_db() db = get_db()
if status: if status:
# Translate human filter names to stored status values. # Translate human filter names to stored status values.
@ -141,7 +144,8 @@ def update_request(request_id, **fields):
def delete_request(request_id): def delete_request(request_id):
"""Delete a single request by id. Does NOT delete associated files.""" """Delete a single request row by id. Does NOT delete associated files
(the caller in app.py removes uploads before/after this call)."""
db = get_db() db = get_db()
db.execute('DELETE FROM requests WHERE id = ?', (request_id,)) db.execute('DELETE FROM requests WHERE id = ?', (request_id,))
db.commit() db.commit()

View file

@ -6,7 +6,10 @@
# flask - web framework # flask - web framework
# gunicorn - production WSGI server used by Dockerfile # gunicorn - production WSGI server used by Dockerfile
# python-dotenv - loads .env files in development # python-dotenv - loads .env files in development
# werkzeug - utilities for file uploads and password hashing # werkzeug - utilities for file uploads and secure filenames
# mutagen - MP3 metadata (ID3) tagging
# flask-limiter - public form rate limiting
# cryptography - encrypt stored SMTP password
flask flask
gunicorn gunicorn

View file

@ -127,6 +127,7 @@
<!-- Status filter links --> <!-- Status filter links -->
<div class="filters"> <div class="filters">
<a href="{{ url_for('admin_dashboard') }}" class="{% if current_status in (None, '') %}active{% endif %}">All</a> <a href="{{ url_for('admin_dashboard') }}" class="{% if current_status in (None, '') %}active{% endif %}">All</a>
<a href="{{ url_for('admin_dashboard', status='pending') }}" class="{% if current_status == 'pending' %}active{% endif %}">Pending</a>
<a href="{{ url_for('admin_dashboard', status='songs_uploaded') }}" class="{% if current_status == 'songs_uploaded' %}active{% endif %}">Needs Upload</a> <a href="{{ url_for('admin_dashboard', status='songs_uploaded') }}" class="{% if current_status == 'songs_uploaded' %}active{% endif %}">Needs Upload</a>
<a href="{{ url_for('admin_dashboard', status='awaiting_payment') }}" class="{% if current_status == 'awaiting_payment' %}active{% endif %}">Awaiting Payment</a> <a href="{{ url_for('admin_dashboard', status='awaiting_payment') }}" class="{% if current_status == 'awaiting_payment' %}active{% endif %}">Awaiting Payment</a>
<a href="{{ url_for('admin_dashboard', status='delivered') }}" class="{% if current_status == 'delivered' %}active{% endif %}">Delivered</a> <a href="{{ url_for('admin_dashboard', status='delivered') }}" class="{% if current_status == 'delivered' %}active{% endif %}">Delivered</a>

View file

@ -9,10 +9,10 @@
Single-request admin detail page. Single-request admin detail page.
Sections: Sections:
1. Customer info (with revisions note if any) 1. Customer info (with revisions note if any)
2. Generate Suno prompt 2. Generate and save Suno prompt
3. Upload Songs 3. Upload Version A and Version B MP3s
4. Notify Customer 4. Send preview email with private player link
5. Payment & Delivery 5. Mark paid and deliver selected MP3(s)
*/ */
body{ body{
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
@ -154,7 +154,7 @@
<!-- Section 2: Generate Suno prompt --> <!-- Section 2: Generate Suno prompt -->
<div class="section"> <div class="section">
<h2>1. Generate Suno Prompt</h2> <h2>1. Generate & Save Suno Prompt</h2>
<button type="button" onclick="copyPromptForHermes()">Copy customer info for Hermes</button> <button type="button" onclick="copyPromptForHermes()">Copy customer info for Hermes</button>
<p class="copy-hint">Paste Hermes' Title, Style, and Lyrics directly into the fields below, then save.</p> <p class="copy-hint">Paste Hermes' Title, Style, and Lyrics directly into the fields below, then save.</p>

View file

@ -7,7 +7,8 @@
<style> <style>
/* /*
Admin settings / maintenance page. Admin settings / maintenance page.
Two-column layout for settings; table-styled MP3 metadata fields. Two-column layout for revision limit, auto-refresh, SMTP config,
MP3 metadata defaults, database backup/restore, health stats, and reset.
*/ */
:root{ :root{
--bg:#0b0f19; --bg:#0b0f19;

View file

@ -11,6 +11,7 @@
plus approval buttons or a revision note form. plus approval buttons or a revision note form.
After the customer makes a choice, the controls are hidden After the customer makes a choice, the controls are hidden
and a confirmation/waiting message is shown instead. and a confirmation/waiting message is shown instead.
The number of remaining revisions is shown when available.
*/ */
body{ body{
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
@ -120,7 +121,7 @@
</div> </div>
{% else %} {% else %}
<!-- Approval form: customer picks A, B, or both --> <!-- Approval form: customer picks Version A, B, or both -->
<form method="POST" action="{{ url_for('approve', token=req.player_token) }}"> <form method="POST" action="{{ url_for('approve', token=req.player_token) }}">
<input type="hidden" name="choice" id="choice"> <input type="hidden" name="choice" id="choice">
<div class="actions"> <div class="actions">