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:
parent
14ed80fe4b
commit
4af9322c9d
11 changed files with 148 additions and 111 deletions
88
README.md
88
README.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
45
REVIEW.md
45
REVIEW.md
|
|
@ -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
59
app.py
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
12
models.py
12
models.py
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Reference in a new issue