feat: add booth open/closed toggle on settings page

This commit is contained in:
Troll (Hermes Agent) 2026-08-02 18:17:59 +00:00
parent c39265ab42
commit 5e26650b90
6 changed files with 96 additions and 6 deletions

View file

@ -6,7 +6,8 @@ A Flask web app for a convention booth where visitors request a custom AI-genera
### Customer-facing ### Customer-facing
- **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. The email field is validated to reduce delivery problems. - **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. The email field is validated to reduce delivery problems. If the operator marks the booth as closed, this page shows a closed banner and message instead.
- **Closed page** (`/request`) — when the booth is marked closed from `/admin/settings`, visitors see the closed banner and a friendly "goblin engineers are on a break" message.
- **Confirmation page** (`/thanks/<id>`) — shows the request number after submission. - **Confirmation page** (`/thanks/<id>`) — shows the request number after submission.
- **FAQ page** (`/faq`) — answers common customer questions. - **FAQ page** (`/faq`) — answers common customer questions.
- **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. - **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.
@ -59,6 +60,7 @@ pending → prompt_ready → songs_uploaded → awaiting_payment → paid → de
| `config.py` | Environment-variable based configuration; defines defaults for DB, uploads, SMTP, and secrets. | | `config.py` | Environment-variable based configuration; defines defaults for DB, uploads, SMTP, and secrets. |
| `models.py` | SQLite schema and CRUD helpers. | | `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/closed.html` | Message shown on `/request` when the booth is marked closed. |
| `templates/request.html` | Customer request form. | | `templates/request.html` | Customer request form. |
| `templates/thanks.html` | Post-submission confirmation. | | `templates/thanks.html` | Post-submission confirmation. |
| `templates/faq.html` | Customer FAQ page. | | `templates/faq.html` | Customer FAQ page. |
@ -69,6 +71,7 @@ pending → prompt_ready → songs_uploaded → awaiting_payment → paid → de
| `templates/admin/settings.html` | Maintenance, settings, backup/restore, and reset 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. | | `static/DM-Logo_email.png` | Inline Dionysis Media logo attached to emails. |
| `static/Booth_closed.png` | Banner shown when the booth is marked closed. |
| `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. |
@ -131,7 +134,8 @@ 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. - **Runtime settings persist.** SMTP config, revision limit, auto-refresh interval, booth open/closed state, and MP3 metadata defaults are stored in `booth_settings.json` inside the persistent uploads volume. They survive redeploys.
- **Booth open/closed switch.** Operators can flip the booth status from `/admin/settings`. When closed, `/request` shows a closed banner and message instead of the form.
- **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. - **MP3 metadata.** Uploaded files are tagged with title (from the saved prompt), plus configured artist/album/year/comment values.

View file

@ -35,6 +35,7 @@ Flask app that lets convention attendees request custom AI-generated theme songs
| `templates/admin/dashboard.html` | Queue table + filters + auto-refresh + 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. | | `templates/admin/settings.html` | SMTP config, MP3 metadata defaults, DB backup/restore, health check, reset. |
| `templates/faq.html` | Customer FAQ page. | | `templates/faq.html` | Customer FAQ page. |
| `templates/closed.html` | Message shown on `/request` when the booth is marked closed. |
| `docker-compose.yml` | No `env_file`; variables come from Portainer. | | `docker-compose.yml` | No `env_file`; variables come from Portainer. |
## Status meanings ## Status meanings
@ -80,7 +81,8 @@ Most can be overridden at runtime from `/admin/settings` and stored in `booth_se
- 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`, then resets `sqlite_sequence`. - 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`). - Runtime settings are stored in the persistent uploads volume (`booth_settings.json`).
- Container cannot read host paths; all static assets used at runtime (logo, banner, favicons) must be in the repo or a mounted volume. - The `booth_open` setting controls whether `/request` shows the form or the closed banner.
- Container cannot read host paths; all static assets used at runtime (logo, banner, favicons, closed banner) must be in the repo or a mounted volume.
## How to redeploy ## How to redeploy

26
app.py
View file

@ -308,6 +308,12 @@ def build_signature_images():
return [(str(logo_path), 'dm-logo')] return [(str(logo_path), 'dm-logo')]
def get_booth_open():
"""Return True if the booth is currently marked as open in runtime settings."""
cfg = load_booth_settings()
return cfg.get('booth_open', True)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Public customer routes # Public customer routes
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -323,11 +329,14 @@ def index():
def request_form(): 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, or a closed message if the booth is closed.
POST -> validates the email, creates a database record, sends a POST -> validates the email, creates a database record, sends a
confirmation email, and redirects to the thanks page. confirmation email, and redirects to the thanks page.
Rate limited to 5 submissions per minute per IP. Rate limited to 5 submissions per minute per IP.
""" """
if not get_booth_open():
return render_template('closed.html')
if request.method == 'POST': if request.method == 'POST':
form_data = { form_data = {
'name': request.form.get('name', '').strip(), 'name': request.form.get('name', '').strip(),
@ -694,9 +703,9 @@ def admin_settings():
""" """
Settings / maintenance page for operators. Settings / maintenance page for operators.
GET -> show database health, statistics, disk usage, runtime settings forms, GET -> show database health, statistics, disk usage, runtime settings forms,
SMTP/email config, MP3 metadata defaults, backup/restore, and reset. booth open/closed switch, SMTP/email config, MP3 metadata defaults, backup/restore, and reset.
POST -> handle one of: fix_db, reset_system, save_max_revisions, save_metadata, 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. save_email_config, send_test_email, save_refresh, save_booth_open, download_db, restore_db.
""" """
redir = require_admin() redir = require_admin()
if redir: if redir:
@ -717,6 +726,7 @@ def admin_settings():
runtime_settings = load_booth_settings() runtime_settings = load_booth_settings()
current_max_revisions = runtime_settings.get('max_revisions', current_app.config.get('MAX_REVISIONS', 2)) current_max_revisions = runtime_settings.get('max_revisions', current_app.config.get('MAX_REVISIONS', 2))
current_refresh_seconds = runtime_settings.get('refresh_seconds', 10) current_refresh_seconds = runtime_settings.get('refresh_seconds', 10)
booth_open = runtime_settings.get('booth_open', True)
# Effective email config to show in the form (non-sensitive only; password left blank). # Effective email config to show in the form (non-sensitive only; password left blank).
email_form = { email_form = {
@ -868,6 +878,15 @@ def admin_settings():
flash(f'Dashboard auto-refresh set to {val} seconds.', 'success') flash(f'Dashboard auto-refresh set to {val} seconds.', 'success')
return redirect(url_for('admin_settings')) return redirect(url_for('admin_settings'))
elif action == 'save_booth_open':
# Toggle whether the public request form is accepting submissions.
cfg = load_booth_settings()
cfg['booth_open'] = request.form.get('booth_open', '1') == '1'
save_booth_settings(cfg)
state = 'open' if cfg['booth_open'] else 'closed'
flash(f'Booth is now {state}.', 'success')
return redirect(url_for('admin_settings'))
elif action == 'download_db': elif action == 'download_db':
# Send the SQLite database file as a download. # Send the SQLite database file as a download.
if db_path.exists(): if db_path.exists():
@ -915,6 +934,7 @@ def admin_settings():
upload_path=str(upload_root), upload_path=str(upload_root),
current_max_revisions=current_max_revisions, current_max_revisions=current_max_revisions,
current_refresh_seconds=current_refresh_seconds, current_refresh_seconds=current_refresh_seconds,
booth_open=booth_open,
email_form=email_form, email_form=email_form,
metadata=runtime_settings, metadata=runtime_settings,
) )

BIN
static/Booth_closed.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

View file

@ -171,6 +171,21 @@
<div class="grid"> <div class="grid">
<!-- Database health check -->
<div class="section">
<h2>Booth Status</h2>
<p class="copy-hint">When closed, the public request page shows a "booth closed" message instead of the form.</p>
<form method="POST">
<input type="hidden" name="action" value="save_booth_open">
<label for="booth_open">Booth is currently</label>
<select id="booth_open" name="booth_open">
<option value="1" {% if booth_open %}selected{% endif %}>Open ✅</option>
<option value="0" {% if not booth_open %}selected{% endif %}>Closed ❌</option>
</select>
<button type="submit">Save Booth Status</button>
</form>
</div>
<!-- Database health check --> <!-- Database health check -->
<div class="section"> <div class="section">
<h2>Database Health</h2> <h2>Database Health</h2>

49
templates/closed.html Normal file
View file

@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Trollgorithm Theme Song Booth — Closed</title>
<style>
/*
Closed booth page.
Friendly message shown when the booth is temporarily not taking requests.
*/
*{box-sizing:border-box;}
body{
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
background:linear-gradient(135deg,#111827 0%,#1e3a8a 50%,#111827 100%);
color:#f3f4f6;
margin:0;
padding:1rem;
line-height:1.6;
min-height:100vh;
}
.container{max-width:680px;margin:0 auto;text-align:center;}
.banner{
width:100%;
border-radius:1rem;
box-shadow:0 10px 40px rgba(0,0,0,.4);
margin-bottom:1.5rem;
}
.card{
background:rgba(31,41,55,.9);
padding:1.5rem;
border-radius:1rem;
box-shadow:0 10px 25px rgba(0,0,0,.3);
border:1px solid rgba(96,165,250,.2);
}
h1{margin-top:0;color:#60a5fa;font-size:1.7rem;}
p{font-size:1.1rem;color:#d1d5db;}
</style>
</head>
<body>
<div class="container">
<img src="{{ url_for('static', filename='Booth_closed.png') }}" alt="Trollgorithm booth is closed" class="banner">
<div class="card">
<h1>Trollgorithm and the goblin engineers are on a break.</h1>
<p>Please come back in a while and they should be back to work!</p>
</div>
</div>
</body>
</html>