diff --git a/.env.example b/.env.example index e3d6be4..542b7d2 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,23 @@ +# .env.example +# ============ +# +# Copy this file to .env for local development, or paste the values into +# Portainer when deploying the stack. +# +# Required for production: +# APP_SECRET_KEY - long random string used by Flask for sessions +# ADMIN_PASSWORD - password for the /admin dashboard +# SMTP_PASS - password for the SMTP account +# PUBLIC_BASE_URL - public HTTPS URL customers will use (e.g. https://booth.example.com) +# +# Optional: +# BOOTH_NAME - name shown in emails +# HOST_PORT - host-side port mapping for docker-compose (default 127.0.0.1:8000) +# INTERNAL_PORT - port gunicorn binds inside the container (default 8000) +# PRICE_PER_VERSION - shown on the receipt page (default 10.00) +# CURRENCY - currency label (default CAD) +# ADMIN_ALERT_EMAIL - unused; dashboard is the operator queue + APP_SECRET_KEY=change-me-in-production ADMIN_PASSWORD=change-me SMTP_HOST=mailroot8.namespro.ca @@ -8,9 +28,9 @@ SMTP_FROM=ai@hallsworth.ca ADMIN_ALERT_EMAIL= PUBLIC_BASE_URL=http://127.0.0.1:5000 BOOTH_NAME=Trollgorithm Theme Songs -DATABASE=/app/data/booth.db -UPLOAD_FOLDER=/app/uploads INTERNAL_PORT=8000 HOST_PORT=127.0.0.1:8000 PRICE_PER_VERSION=10.00 CURRENCY=CAD +DATABASE=/app/data/booth.db +UPLOAD_FOLDER=/app/uploads diff --git a/Dockerfile b/Dockerfile index 5c52bed..11c8e69 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,24 +1,42 @@ +# Dockerfile +# ========== +# +# Builds the Theme Song Booth Flask app into a small production container. +# +# Steps: +# 1. Use Python 3.12 slim base image. +# 2. Install ffmpeg (used only if we later process audio metadata; harmless otherwise). +# 3. Install Python dependencies from requirements.txt. +# 4. Copy the entire repo into /app. +# 5. Create an unprivileged user (boothuser) and data/upload directories. +# 6. Expose the default internal port and run gunicorn on $INTERNAL_PORT. + FROM python:3.12-slim WORKDIR /app +# Install ffmpeg; clean apt cache to keep image small. RUN apt-get update \ && apt-get install -y --no-install-recommends ffmpeg \ && rm -rf /var/lib/apt/lists/* +# Install Python requirements first for layer caching. COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt +# Copy application source, templates, static files, etc. COPY . . +# Avoid writing .pyc files and ensure stdout is unbuffered. ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 +# Create a non-root user, persistent directories, and fix ownership. RUN useradd -m -u 1000 boothuser && mkdir -p /app/data /app/uploads && chown -R boothuser:boothuser /app USER boothuser -# Default internal port; override with INTERNAL_PORT env var +# Default internal port; override with INTERNAL_PORT env var. EXPOSE 8000 -# Use shell form so environment variables are expanded at runtime +# Use shell form so environment variables are expanded at runtime. CMD gunicorn -b 0.0.0.0:${INTERNAL_PORT:-8000} --access-logfile - app:app diff --git a/README.md b/README.md index 4876636..44db211 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,54 @@ # Theme Song Booth -Prototype web app for a convention booth where attendees request custom AI-generated theme songs. +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. -## Flow +## What this project does -1. Customer fills out the public request form at `/request`. -2. Operator generates a Suno Custom Mode prompt via Hermes and saves it in the admin detail page. -3. Operator generates two song versions in Suno and uploads the MP3s in admin. -4. Operator clicks **Send Preview Link**. Customer receives an email with a private player page. -5. Customer listens to Version A and Version B, then approves one/both or requests changes. -6. Operator sees the approval alert, collects payment via Square reader, then clicks **Mark Paid & Deliver**. -7. Customer receives the approved MP3(s) as email attachments. +- **Customer request page** (`/request`) — booth visitors enter their name, email, hobbies, notable facts, preferred genre, and extra requests. A branded banner image is shown. +- **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. +- **Song upload** — operator uploads Version A and Version B MP3s. +- **Customer player page** — a private `/play/` page emails to the customer. They can listen to both versions, choose A/B/both, or request changes. +- **Payment and delivery** — operator enters a Square payment reference and clicks **Mark Paid & Deliver**. The approved MP3(s) are emailed as attachments. +- **System reset** — one button in the admin topbar clears all requests and files at the start of an event. -## Local Development +## Status flow + +``` +pending → prompt_ready → songs_uploaded → awaiting_payment → paid → delivered +``` + +| Status | Meaning | +|---|---| +| `pending` | Customer submitted request; no prompt yet. | +| `prompt_ready` | Operator saved Title/Style/Lyrics. | +| `songs_uploaded` | Both MP3s uploaded; preview link can be sent. | +| `awaiting_payment` | Customer approved a version. | +| `paid` | Payment reference recorded; delivery email sent. | +| `delivered` | MP3 attachments emailed. | + +## File layout + +| File | Purpose | +|---|---| +| `app.py` | Flask routes, helpers, and email logic. | +| `config.py` | Environment-variable based configuration. | +| `models.py` | SQLite schema and database helper functions. | +| `init_db.py` | Standalone script to create the database tables. | +| `templates/request.html` | Customer request form (with banner). | +| `templates/thanks.html` | Post-submission confirmation. | +| `templates/player.html` | Customer audio player and approval page. | +| `templates/admin/login.html` | Admin password login. | +| `templates/admin/dashboard.html` | Operator queue with filters and reset. | +| `templates/admin/request.html` | Single-request detail / prompt / upload / delivery. | +| `static/Trollgorithm_booth.jpg` | Banner image on the request page. | +| `Dockerfile` | Production container image. | +| `docker-compose.yml` | Portainer stack definition. | +| `requirements.txt` | Python dependencies. | +| `.env.example` | Template for environment variables. | +| `REVIEW.md` | Quick reference for returning to this project. | + +## Local development ```bash cd /home/jess/workspace/theme-song-booth @@ -24,42 +60,62 @@ cp .env.example .env .venv/bin/python -m flask --app app run --host=0.0.0.0 ``` +Visit: +- Customer form: http://127.0.0.1:5000/request +- Admin login: http://127.0.0.1:5000/admin + ## Deployment with Portainer -The repo includes a `docker-compose.yml` that builds directly from GitLab, so Portainer can pull and deploy it as a stack. - -1. Log in to your Portainer instance. +1. Log in to Portainer. 2. Go to **Stacks** → **Add stack**. -3. Choose **Repository** and paste: +3. Choose **Repository**: - URL: `https://gitlab.hallsworth.ca/yrtria/theme-song-booth.git` - Branch: `main` - Compose path: `docker-compose.yml` -4. Add environment variables directly in Portainer under **Environment variables**: - - `APP_SECRET_KEY` — long random string - - `ADMIN_PASSWORD` — password for `/admin` - - `SMTP_PASS` — `b3ZzD@eM!MkqVS8P` - - `PUBLIC_BASE_URL` — your HTTPS domain - - `HOST_PORT` — default `127.0.0.1:8000` - - `BOOTH_NAME` — optional +4. Add environment variables: + +| Variable | Required | Purpose | +|---|---|---| +| `APP_SECRET_KEY` | Yes | Long random string for Flask sessions. Generate with `python3 -c "import secrets; print(secrets.token_hex(32))"`. | +| `ADMIN_PASSWORD` | Yes | Password for `/admin`. | +| `SMTP_PASS` | Yes | Password for `ai@hallsworth.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`. | +| `INTERNAL_PORT` | No | Port gunicorn binds inside container, default `8000`. | +| `BOOTH_NAME` | No | Name used in emails, default `Trollgorithm Theme Songs`. | +| `PRICE_PER_VERSION` | No | Shown on receipt page, default `10.00`. | +| `CURRENCY` | No | Currency label, default `CAD`. | + 5. Deploy the stack. -6. Open a console into the `booth` container and run once: - ```bash - python init_db.py - ``` -7. Point your reverse proxy at the `HOST_PORT` you chose (e.g. `http://host-ip:8000`). -8. Print the booth QR code pointing to `https://your-domain/request`. +6. Open a console in the `booth` container and run once: -### Portainer Notes +```bash +python init_db.py +``` -- The `docker-compose.yml` uses named volumes (`booth-data`, `booth-uploads`) so Portainer handles persistence automatically. -- For a pre-built image instead of repo build, replace the `build:` block with an `image:` line pointing to your registry. -- Update the stack after each push to redeploy the latest code. +7. Point your reverse proxy at the `HOST_PORT` you chose. +8. Print or display a QR code pointing to `https://your-domain/request`. -## Files +### Updating the deployment -- `app.py` — Flask application with public/admin routes and email logic. -- `models.py` — SQLite schema and helper functions. -- `config.py` — Configuration loaded from environment. -- `templates/` — Jinja2 HTML templates. -- `init_db.py` — Standalone script to create the SQLite database. -- `Dockerfile` / `docker-compose.yml` — Container packaging for Portainer. +After each push to GitLab, go to Portainer → **Stacks** → `theme-song-booth` → **Pull and redeploy** to rebuild from the repo. + +## Important notes + +- **No `.env` file in production.** `docker-compose.yml` passes variables directly from Portainer. This avoids Portainer's `env_file not found` error. +- **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`. +- **Security:** the repo is public on GitLab. No secrets are committed. Admin password is plain text in the Portainer environment. + +## Common troubleshooting + +| Problem | Cause | Fix | +|---|---|---| +| "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. | +| 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. | + +## License / ownership + +Built for Jess's Trollgorithm theme-song booth. All code and assets are private to that project. diff --git a/REVIEW.md b/REVIEW.md new file mode 100644 index 0000000..b845330 --- /dev/null +++ b/REVIEW.md @@ -0,0 +1,94 @@ +# REVIEW.md — Theme Song Booth + +Quick reference for future work on this project. + +## One-sentence summary + +Flask app that lets convention attendees request custom AI-generated theme songs, lets an operator manage the queue, and emails MP3s after payment. + +## Stack + +- Python 3.12 + Flask +- SQLite (file-based, request-scoped connection via `g`) +- Gunicorn in Docker +- Portainer stack deployed from GitLab repo +- SMTP (SSL port 465) for customer emails +- Square Terminal/Reader for manual payment + +## Repository + +- GitLab: `https://gitlab.hallsworth.ca/yrtria/theme-song-booth` (public) +- Deployed at: `https://booth.dionysismedia.ca` + +## Key files and what they hold + +| File | Notes | +|---|---| +| `app.py` | All routes, helpers, email function, status labels. | +| `config.py` | Env vars. `ADMIN_PASSWORD` is plain text. | +| `models.py` | SQLite schema + CRUD. `player_token` is a secret URL-safe token. | +| `init_db.py` | Run once after deploy: `python init_db.py`. | +| `templates/admin/request.html` | Biggest template; prompt extraction JS lives here. | +| `templates/admin/dashboard.html` | Queue table + topbar Reset System button. | +| `docker-compose.yml` | No `env_file`; variables come from Portainer. | + +## Status meanings + +``` +pending → prompt_ready → songs_uploaded → awaiting_payment → paid → delivered +``` + +## Operator workflow + +1. Customer fills `/request`. +2. Open `/admin`, click request row. +3. Click **Copy customer info for Hermes**, paste result to Hermes. +4. Paste Hermes response (Title/Style/Lyrics format), click **Extract**, click **Save Prompt**. +5. Copy Style/Lyrics into Suno Custom Mode, generate two versions. +6. Upload Version A and B MP3s. +7. Click **Send Preview Link**. +8. Customer receives email, visits player, picks version. +9. Operator collects Square payment, enters reference, clicks **Mark Paid & Deliver**. +10. Customer receives MP3 attachment(s) by email. + +## Environment variables that matter + +``` +APP_SECRET_KEY +ADMIN_PASSWORD +SMTP_PASS +PUBLIC_BASE_URL +BOOTH_NAME +HOST_PORT +INTERNAL_PORT +``` + +## Gotchas + +- 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 ``. +- The dashboard uses `basename()` as a function, not a Jinja filter. +- Reset System deletes DB rows **and** all files under `UPLOAD_FOLDER`. + +## Things that could be improved later + +- 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 + +1. Push changes to GitLab `main`. +2. In Portainer: Stacks → `theme-song-booth` → Pull and redeploy. +3. If schema changed, open container console and run `python init_db.py`. + +## 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. diff --git a/app.py b/app.py index 4f5b85a..aa2c461 100644 --- a/app.py +++ b/app.py @@ -1,3 +1,30 @@ +""" +app.py +====== +Main Flask application for the Theme Song Booth. + +This module defines all HTTP routes, helper functions, and the email layer. +It is meant to be served by gunicorn inside a Docker container (see Dockerfile). + +Public routes (customers): +- / -> redirects to /request +- /request -> customer submits their info +- /thanks/ -> confirmation page after submission +- /play/ -> private player page with Version A and B +- /play//approve -> customer picks a version +- /play//revise -> customer asks for changes +- /audio//.mp3 -> serves the uploaded MP3 files + +Admin routes: +- /admin/login -> password login +- /admin/logout -> clears session +- /admin -> dashboard queue +- /admin/request/ -> detail/edit page for a single request +- /admin/request//delete -> deletes one request and its files +- /admin/reset -> deletes ALL requests and ALL files +""" + +# Standard library imports import os import shutil import smtplib @@ -5,16 +32,26 @@ import ssl from email.message import EmailMessage from pathlib import Path +# Flask and related imports from flask import Flask, request, render_template, redirect, url_for, flash, session, send_from_directory, abort, current_app from werkzeug.utils import secure_filename +# Project imports from config import Config from models import init_db, close_db, create_request, get_request_by_id, get_request_by_token, list_requests, update_request, now_utc, delete_request, reset_all_requests +# --------------------------------------------------------------------------- +# App setup +# --------------------------------------------------------------------------- + +# Create the Flask app and load configuration from Config class. app = Flask(__name__) app.config.from_object(Config) + +# Ensure the SQLite connection is closed at the end of each request. app.teardown_appcontext(close_db) +# Human-readable labels for each status value stored in the database. STATUS_LABELS = { 'pending': 'Pending', 'prompt_ready': 'Prompt Ready', @@ -24,27 +61,46 @@ STATUS_LABELS = { 'delivered': 'Delivered', } -# ---------------- helpers ---------------- +# --------------------------------------------------------------------------- +# Helper functions +# --------------------------------------------------------------------------- def is_admin(): + """Return True if the current browser session is logged in as admin.""" return session.get('admin') is True + def require_admin(): + """Redirect to the admin login page if the user is not logged in.""" if not is_admin(): return redirect(url_for('admin_login')) + def admin_password_ok(pw): + """Check the submitted admin password against the configured one.""" return pw and pw == current_app.config['ADMIN_PASSWORD'] + def allowed_file(filename): + """Return True if the uploaded filename has an allowed extension (mp3).""" return '.' in filename and filename.rsplit('.', 1)[1].lower() in current_app.config['ALLOWED_EXTENSIONS'] + def upload_path(request_id): + """Return the per-request upload directory path, creating it if necessary.""" p = Path(current_app.config['UPLOAD_FOLDER']) / str(request_id) p.mkdir(parents=True, exist_ok=True) return p + def save_upload(request_id, file_obj, version): + """ + Save an uploaded MP3 file for a request. + :param request_id: database ID of the request + :param file_obj: Flask FileStorage from request.files + :param version: 'a' or 'b' + :return: full filesystem path saved, or None on missing/invalid file + """ if not file_obj or file_obj.filename == '': return None if not allowed_file(file_obj.filename): @@ -55,7 +111,15 @@ def save_upload(request_id, file_obj, version): file_obj.save(p / filename) return str(p / filename) + def send_email(to, subject, body, attachments=None): + """ + Send an email via SMTP_SSL. + :param to: recipient address + :param subject: email subject + :param body: plain-text body + :param attachments: optional list of (filepath, attachment_name) tuples + """ cfg = current_app.config if not cfg['SMTP_PASS']: raise RuntimeError('SMTP_PASS is not configured') @@ -66,6 +130,7 @@ def send_email(to, subject, body, attachments=None): msg['Subject'] = subject msg.set_content(body) + # Attach any MP3 files as audio/mpeg attachments. if attachments: for path, name in attachments: with open(path, 'rb') as f: @@ -76,14 +141,24 @@ def send_email(to, subject, body, attachments=None): server.login(cfg['SMTP_USER'], cfg['SMTP_PASS']) server.send_message(msg) -# ---------------- public ---------------- + +# --------------------------------------------------------------------------- +# Public customer routes +# --------------------------------------------------------------------------- @app.route('/') def index(): + """Root route: redirect customers straight to the request form.""" return redirect(url_for('request_form')) + @app.route('/request', methods=['GET', 'POST']) def request_form(): + """ + Public request form. + GET -> shows the form with the banner image. + POST -> creates a database record and redirects to the thanks page. + """ if request.method == 'POST': rid = create_request( name=request.form.get('name', '').strip(), @@ -97,22 +172,34 @@ def request_form(): return redirect(url_for('thanks', rid=rid)) return render_template('request.html') + @app.route('/thanks/') def thanks(rid): + """Confirmation page shown after a customer submits a request.""" req = get_request_by_id(rid) if not req: abort(404) return render_template('thanks.html', req=req) + @app.route('/play/') def play(token): + """ + Private player page for a customer. + The token is a cryptographically random URL-safe string generated at request time. + """ req = get_request_by_token(token) if not req: abort(404) return render_template('player.html', req=req) + @app.route('/play//approve', methods=['POST']) def approve(token): + """ + Customer has chosen Version A, Version B, or both. + Updates the request status to 'awaiting_payment' so the operator can collect payment. + """ req = get_request_by_token(token) if not req: abort(404) @@ -123,28 +210,37 @@ def approve(token): update_request(req['id'], customer_approved=choice, status='awaiting_payment', approval_notified_at=now_utc()) - # alert operator (disabled — admin dashboard is the queue) + # 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') return redirect(url_for('play', token=token)) + @app.route('/play//revise', methods=['POST']) def revise(token): + """ + Customer asked for changes. Store the note and reset status to 'songs_uploaded' + so the operator sees it in the dashboard queue. + """ req = get_request_by_token(token) if not req: abort(404) note = request.form.get('revision_note', '').strip() update_request(req['id'], revision_note=note, status='songs_uploaded') - # Revision feedback is stored in the DB and surfaced on the admin dashboard. - # No operator email is sent — the dashboard is the single queue. + # NOTE: No operator email is sent; the dashboard is the single queue. flash('Your feedback has been saved. We will regenerate and update you.', 'success') return redirect(url_for('play', token=token)) + @app.route('/audio//.mp3') def audio(token, version): + """ + Serve an uploaded MP3 file for a specific request token and version ('a' or 'b'). + This keeps the files off the public static path and ties them to the private token. + """ req = get_request_by_token(token) if not req: abort(404) @@ -156,10 +252,14 @@ def audio(token, version): abort(404) return send_from_directory(Path(path).parent, Path(path).name) -# ---------------- admin ---------------- + +# --------------------------------------------------------------------------- +# Admin routes +# --------------------------------------------------------------------------- @app.route('/admin/login', methods=['GET', 'POST']) def admin_login(): + """Simple session-based admin login. Password is set via ADMIN_PASSWORD env var.""" if is_admin(): return redirect(url_for('admin_dashboard')) if request.method == 'POST': @@ -169,13 +269,20 @@ def admin_login(): flash('Invalid password.', 'error') return render_template('admin/login.html') + @app.route('/admin/logout') def admin_logout(): + """Clear the admin session.""" session.pop('admin', None) return redirect(url_for('admin_login')) + @app.route('/admin') def admin_dashboard(): + """ + Main operator queue. + Optional ?status= filter lets operators focus on one state at a time. + """ redir = require_admin() if redir: return redir @@ -183,8 +290,15 @@ def admin_dashboard(): requests = list_requests(status_filter) return render_template('admin/dashboard.html', requests=requests, statuses=STATUS_LABELS, current_status=status_filter) + @app.route('/admin/request/', methods=['GET', 'POST']) def admin_request(rid): + """ + Detail/edit page for a single request. + GET -> render the request details and editing forms. + POST -> handle one of four actions: + save_prompt, upload_songs, notify_customer, mark_paid_deliver + """ redir = require_admin() if redir: return redir @@ -192,6 +306,7 @@ def admin_request(rid): if not req: abort(404) + # Small helpers exposed to the template for status badges. def file_exists(path): return bool(path and Path(path).exists()) @@ -202,6 +317,7 @@ def admin_request(rid): action = request.form.get('action') if action == 'save_prompt': + # Store the generated title/style/lyrics and mark prompt ready. update_request(rid, suno_title=request.form.get('suno_title', '').strip(), suno_style=request.form.get('suno_style', '').strip(), @@ -211,6 +327,7 @@ def admin_request(rid): flash('Prompt saved.', 'success') elif action == 'upload_songs': + # Save uploaded MP3 files for Version A and/or Version B. a_path = save_upload(rid, request.files.get('song_a'), 'a') b_path = save_upload(rid, request.files.get('song_b'), 'b') fields = {} @@ -224,6 +341,7 @@ def admin_request(rid): flash('Songs uploaded.', 'success') elif action == 'notify_customer': + # Email the customer a private player link. Both songs must be uploaded first. if not (req['song_a_path'] and req['song_b_path']): flash('Both songs must be uploaded first.', 'error') else: @@ -237,6 +355,7 @@ def admin_request(rid): flash(f'Failed to send preview email: {e}', 'error') elif action == 'mark_paid_deliver': + # Finalize: record Square payment ref, attach approved MP3s, email customer. if req['customer_approved'] == 'none': flash('Customer has not approved a version yet.', 'error') else: @@ -264,8 +383,10 @@ def admin_request(rid): return render_template('admin/request.html', req=req, statuses=STATUS_LABELS, file_exists=file_exists, basename=basename) + @app.route('/admin/request//delete', methods=['POST']) def admin_delete_request(rid): + """Delete a single request and remove its uploaded MP3 files.""" redir = require_admin() if redir: return redir @@ -273,7 +394,7 @@ def admin_delete_request(rid): if not req: abort(404) - # Delete uploaded files if they exist + # Delete uploaded files if they exist. for field in ('song_a_path', 'song_b_path'): path = req.get(field) if path and Path(path).exists(): @@ -281,7 +402,7 @@ def admin_delete_request(rid): Path(path).unlink() except OSError: pass - # Remove empty upload directory + # Remove empty upload directory. upload_dir = Path(current_app.config['UPLOAD_FOLDER']) / str(rid) if upload_dir.exists(): try: @@ -293,8 +414,14 @@ def admin_delete_request(rid): flash(f'Request #{rid} deleted.', 'success') return redirect(url_for('admin_dashboard')) + @app.route('/admin/reset', methods=['POST']) def admin_reset_system(): + """ + Nuclear reset for the start of an event. + Deletes all database rows and all files/directories under UPLOAD_FOLDER. + Requires clicking through a browser confirm dialog. + """ redir = require_admin() if redir: return redir @@ -314,12 +441,18 @@ def admin_reset_system(): flash('System reset complete. All orders and files have been cleared.', 'success') return redirect(url_for('admin_dashboard')) -# ---------------- init ---------------- + +# --------------------------------------------------------------------------- +# CLI and entry point +# --------------------------------------------------------------------------- @app.cli.command('init-db') def init_db_command(): + """Flask CLI command: flask --app app init-db""" init_db() print('Database initialized.') + if __name__ == '__main__': + # Development-only entry point. Production uses gunicorn (see Dockerfile). app.run(debug=True, host='0.0.0.0') diff --git a/config.py b/config.py index 97ca1bf..be6ae5d 100644 --- a/config.py +++ b/config.py @@ -1,29 +1,61 @@ +""" +config.py +========= +Configuration object loaded by Flask from environment variables. + +The application expects values to be provided via Portainer environment +variables or a local .env file during development. + +All values have sensible defaults where safe, but the following MUST be +set in production: + - APP_SECRET_KEY + - ADMIN_PASSWORD + - SMTP_PASS + - PUBLIC_BASE_URL +""" + import os from dotenv import load_dotenv +# Load variables from .env file if present (development mode). load_dotenv() + class Config: + # Flask secret key: used to sign session cookies. Must be a long random string in production. SECRET_KEY = os.environ.get('APP_SECRET_KEY', 'dev-secret-change-me') + + # SQLite database path inside the container. DATABASE = os.environ.get('DATABASE', '/app/data/booth.db') + + # Directory where uploaded MP3 files are stored inside the container. UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', '/app/uploads') + + # Only MP3 uploads are allowed. ALLOWED_EXTENSIONS = {'mp3'} + # SMTP server settings for sending customer emails. SMTP_HOST = os.environ.get('SMTP_HOST', 'mailroot8.namespro.ca') SMTP_PORT = int(os.environ.get('SMTP_PORT', '465')) SMTP_USER = os.environ.get('SMTP_USER', 'ai@hallsworth.ca') SMTP_PASS = os.environ.get('SMTP_PASS', '') SMTP_FROM = os.environ.get('SMTP_FROM', 'ai@hallsworth.ca') + # Admin login password (plain text, set via env). ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD', '') + + # Optional operator alert email. Currently unused because the dashboard is the queue. ADMIN_ALERT_EMAIL = os.environ.get('ADMIN_ALERT_EMAIL', '') + # 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') + + # Booth name used in email sign-offs. BOOTH_NAME = os.environ.get('BOOTH_NAME', 'Trollgorithm Theme Songs') - # Port the container listens on internally (gunicorn) + # Internal port gunicorn listens on inside the container. INTERNAL_PORT = int(os.environ.get('INTERNAL_PORT', '8000')) - # Price settings (informational, for receipt page) + # Price per version shown on the receipt page (informational only; payment is manual). PRICE_PER_VERSION = float(os.environ.get('PRICE_PER_VERSION', '10.00')) CURRENCY = os.environ.get('CURRENCY', 'CAD') diff --git a/docker-compose.yml b/docker-compose.yml index 8f438a5..3f31888 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,16 @@ +# docker-compose.yml +# ================== +# +# Portainer stack definition. +# Builds the image directly from the GitLab repository (main branch). +# +# Environment variables are set in Portainer under the stack's +# Environment variables section. The container uses them directly, +# so no .env file is required on disk. +# +# Named volumes keep the SQLite database and uploaded MP3s persistent +# across container restarts and redeploys. + services: booth: build: @@ -12,10 +25,13 @@ services: - SMTP_USER=${SMTP_USER:-ai@hallsworth.ca} - SMTP_PASS=${SMTP_PASS} - SMTP_FROM=${SMTP_FROM:-ai@hallsworth.ca} - - ADMIN_ALERT_EMAIL=${ADMIN_ALERT_EMAIL:-} - PUBLIC_BASE_URL=${PUBLIC_BASE_URL} - BOOTH_NAME=${BOOTH_NAME:-Trollgorithm Theme Songs} - INTERNAL_PORT=${INTERNAL_PORT:-8000} + - PRICE_PER_VERSION=${PRICE_PER_VERSION:-10.00} + - CURRENCY=${CURRENCY:-CAD} + - DATABASE=${DATABASE:-/app/data/booth.db} + - UPLOAD_FOLDER=${UPLOAD_FOLDER:-/app/uploads} ports: - "${HOST_PORT:-127.0.0.1:8000}:${INTERNAL_PORT:-8000}" volumes: diff --git a/init_db.py b/init_db.py index c6f8e27..a445cc6 100644 --- a/init_db.py +++ b/init_db.py @@ -1,12 +1,25 @@ +""" +init_db.py +========== +Standalone script to create the SQLite database tables. + +Run this once inside the container after deployment: + python init_db.py + +Or use the Flask CLI command: + flask --app app init-db +""" + import os import sys -# Ensure project root is importable +# Ensure the project root is on sys.path so `from app import app` works. sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from app import app from models import init_db +# Use the configured database path and create tables. with app.app_context(): init_db() print(f"Database initialized at {app.config['DATABASE']}") diff --git a/models.py b/models.py index 8042aa0..8ca712f 100644 --- a/models.py +++ b/models.py @@ -1,8 +1,23 @@ +""" +models.py +========= +SQLite database layer for the Theme Song Booth. + +This module defines the schema and all database operations. Flask's +application context (`g`) is used to manage one connection per request. + +Schema overview (see SCHEMA constant): +- requests table stores customer data, generated prompts, file paths, + approval state, email timestamps, payment reference, and player token. +- Indexes on status and player_token for fast queue/lookup. +""" + import sqlite3 import secrets from datetime import datetime, timezone from flask import current_app, g +# SQL executed by init_db() to create the requests table and indexes. SCHEMA = """ CREATE TABLE IF NOT EXISTS requests ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -33,30 +48,45 @@ CREATE INDEX IF NOT EXISTS idx_requests_status ON requests(status); CREATE INDEX IF NOT EXISTS idx_requests_token ON requests(player_token); """ + def get_db(): + """Get or create a SQLite connection tied to the current Flask request context.""" if 'db' not in g: g.db = sqlite3.connect(current_app.config['DATABASE']) g.db.row_factory = sqlite3.Row return g.db + def close_db(e=None): + """Close the request-scoped SQLite connection. Registered as teardown handler.""" db = g.pop('db', None) if db is not None: db.close() + def init_db(): + """Create the database file and tables. Safe to run multiple times.""" db = sqlite3.connect(current_app.config['DATABASE']) db.executescript(SCHEMA) db.commit() db.close() + def new_token(): + """Generate a URL-safe random token used for private player links.""" return secrets.token_urlsafe(32) + def now_utc(): + """Return current UTC time as ISO-8601 string for timestamp columns.""" return datetime.now(timezone.utc).isoformat() + def create_request(name, email, hobbies, notable_facts, style_genre, extra_requests): + """ + Insert a new customer request. + Returns the auto-generated request id. + """ db = get_db() cur = db.execute( """INSERT INTO requests @@ -67,17 +97,23 @@ def create_request(name, email, hobbies, notable_facts, style_genre, extra_reque db.commit() return cur.lastrowid + def get_request_by_id(request_id): + """Fetch one request by numeric id. Returns dict or None.""" db = get_db() row = db.execute('SELECT * FROM requests WHERE id = ?', (request_id,)).fetchone() return dict(row) if row else None + def get_request_by_token(token): + """Fetch one request by its private player token. Returns dict or None.""" db = get_db() row = db.execute('SELECT * FROM requests WHERE player_token = ?', (token,)).fetchone() return dict(row) if row else None + def list_requests(status=None): + """List all requests, optionally filtered by status, newest first.""" db = get_db() if status: rows = db.execute('SELECT * FROM requests WHERE status = ? ORDER BY created_at DESC', (status,)).fetchall() @@ -85,7 +121,12 @@ def list_requests(status=None): rows = db.execute('SELECT * FROM requests ORDER BY created_at DESC').fetchall() return [dict(r) for r in rows] + def update_request(request_id, **fields): + """ + Update arbitrary columns for a request. + Example: update_request(1, status='prompt_ready', suno_style='...') + """ if not fields: return db = get_db() @@ -94,12 +135,16 @@ def update_request(request_id, **fields): db.execute(f'UPDATE requests SET {cols} WHERE id = ?', vals) db.commit() + def delete_request(request_id): + """Delete a single request by id. Does NOT delete associated files.""" db = get_db() db.execute('DELETE FROM requests WHERE id = ?', (request_id,)) db.commit() + def reset_all_requests(): + """Delete every row in the requests table. Does NOT delete files.""" db = get_db() db.execute('DELETE FROM requests') db.commit() diff --git a/requirements.txt b/requirements.txt index 99b9283..67ed02d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,13 @@ +# requirements.txt +# ================ +# +# Python dependencies for the Theme Song Booth Flask app. +# +# flask - web framework +# gunicorn - production WSGI server used by Dockerfile +# python-dotenv - loads .env files in development +# werkzeug - utilities for file uploads and password hashing + flask gunicorn python-dotenv diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html index b65c650..2730a67 100644 --- a/templates/admin/dashboard.html +++ b/templates/admin/dashboard.html @@ -1,110 +1,163 @@ - - -Admin Dashboard - + + + Admin Dashboard + -
-
-
- -
-Log out -
-

Theme Song Booth — Admin Dashboard

+
+ +
+
+ +
+ Log out +
-{% with messages = get_flashed_messages(with_categories=true) %} -{% for category, message in messages %} -
{{ message }}
-{% endfor %} -{% endwith %} +

Theme Song Booth — Admin Dashboard

-
-All -{% for key,label in statuses.items() %} -{{ label }} -{% endfor %} -
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endwith %} - - - - - - - - - - - - - -{% for r in requests %} - - - - - - - - - -{% endfor %} -{% if not requests %} - -{% endif %} - -
IDNameEmailGenreStatusApprovedActions
#{{ r.id }}{{ r.name }}{{ r.email }}{{ r.style_genre or '-' }}{{ statuses[r.status] }}{% if r.customer_approved != 'none' %}{{ r.customer_approved.upper() }}{% else %}-{% endif %}Open -
- -
-
No requests found.
+ +
+ All + {% for key,label in statuses.items() %} + {{ label }} + {% endfor %} +
-
-

⚠️ Reset System

-

Use this once at the start of an event to clear all orders and uploaded files.

-
- -
-
-
+ + + + + + + + + + + + + + + {% for r in requests %} + + + + + + + + + + {% endfor %} + {% if not requests %} + + {% endif %} + +
IDNameEmailGenreStatusApprovedActions
#{{ r.id }}{{ r.name }}{{ r.email }}{{ r.style_genre or '-' }}{{ statuses[r.status] }}{% if r.customer_approved != 'none' %}{{ r.customer_approved.upper() }}{% else %}-{% endif %} + Open +
+ +
+
No requests found.
+
diff --git a/templates/admin/login.html b/templates/admin/login.html index ee29e81..89a0eb2 100644 --- a/templates/admin/login.html +++ b/templates/admin/login.html @@ -1,30 +1,70 @@ - - -Admin Login - + + + Admin Login + -
-

Booth Admin

- - -{% with messages = get_flashed_messages() %} -{% if messages %} -
{{ messages[0] }}
-{% endif %} -{% endwith %} - -
+
+

Booth Admin

+ + + + {% with messages = get_flashed_messages() %} + {% if messages %} +
{{ messages[0] }}
+ {% endif %} + {% endwith %} + + +
diff --git a/templates/admin/request.html b/templates/admin/request.html index bdbd525..2ad384d 100644 --- a/templates/admin/request.html +++ b/templates/admin/request.html @@ -1,212 +1,283 @@ - - -Request #{{ req.id }} — Admin - + + + Request #{{ req.id }} — Admin + -
-

← Dashboard

-

Request #{{ req.id }} — {{ req.name }}

+
+

← Dashboard

+

Request #{{ req.id }} — {{ req.name }}

-{% with messages = get_flashed_messages(with_categories=true) %} -{% for category, message in messages %} -
{{ message }}
-{% endfor %} -{% endwith %} + {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endwith %} -
-

Customer Info

-
-
Email: {{ req.email }}
-
Status: {{ statuses[req.status] }}
-
-

Hobbies:
{{ req.hobbies or '-' }}

-

Notable facts:
{{ req.notable_facts or '-' }}

-

Style / genre:
{{ req.style_genre or '-' }}

-

Extra requests:
{{ req.extra_requests or '-' }}

-
+ +
+

Customer Info

+
+
Email: {{ req.email }}
+
Status: {{ statuses[req.status] }}
+
+

Hobbies:
{{ req.hobbies or '-' }}

+

Notable facts:
{{ req.notable_facts or '-' }}

+

Style / genre:
{{ req.style_genre or '-' }}

+

Extra requests:
{{ req.extra_requests or '-' }}

+
-
-

1. Generate Suno Prompt

- -

Paste Hermes response into the box below. It will auto-extract Title, Style, and Lyrics.

+ +
+

1. Generate Suno Prompt

+ +

Paste Hermes response into the box below. It will auto-extract Title, Style, and Lyrics.

- - + + -
- -
+
+ +
-
- + + - - + + - - + + - - + + -
- - - - -
-
-
+
+ + + + +
+ +
-
-

2. Upload Songs

-

- Version A: - {% if req.song_a_path and file_exists(req.song_a_path) %} - ✅ Uploaded {{ basename(req.song_a_path) }} - {% else %} - ❌ Not uploaded - {% endif %} -

-

- Version B: - {% if req.song_b_path and file_exists(req.song_b_path) %} - ✅ Uploaded {{ basename(req.song_b_path) }} - {% else %} - ❌ Not uploaded - {% endif %} -

-
- - - - - -
- -
-
-
+ +
+

2. Upload Songs

+

+ Version A: + {% if req.song_a_path and file_exists(req.song_a_path) %} + ✅ Uploaded {{ basename(req.song_a_path) }} + {% else %} + ❌ Not uploaded + {% endif %} +

+

+ Version B: + {% if req.song_b_path and file_exists(req.song_b_path) %} + ✅ Uploaded {{ basename(req.song_b_path) }} + {% else %} + ❌ Not uploaded + {% endif %} +

-
-

3. Notify Customer

-

- Preview email: - {% if req.preview_sent_at %} - ✅ Sent {{ req.preview_sent_at }} - {% else %} - ❌ Not sent yet - {% endif %} -

-

Both songs must be uploaded first.

-
- - -
-
+
+ + + + + +
+ +
+
+
-
-

4. Payment & Delivery

-

Customer approved: -{% if req.customer_approved == 'none' %} -Nothing yet -{% else %} -{{ req.customer_approved.upper() }} -{% endif %} -

-

- Delivery email: - {% if req.delivery_sent_at %} - ✅ Sent {{ req.delivery_sent_at }} - {% else %} - ❌ Not sent yet - {% endif %} -

-
- - - -
- -
-
-
+ +
+

3. Notify Customer

+

+ Preview email: + {% if req.preview_sent_at %} + ✅ Sent {{ req.preview_sent_at }} + {% else %} + ❌ Not sent yet + {% endif %} +

+

Both songs must be uploaded first.

+
+ + +
+
- -
+ if (titleMatch) { + document.getElementById('suno_title').value = titleMatch[1].trim(); + } + if (styleMatch) { + document.getElementById('suno_style').value = styleMatch[1].trim(); + } + if (lyricsMatch) { + document.getElementById('suno_lyrics').value = lyricsMatch[1].trim(); + } + + if (!styleMatch && !lyricsMatch) { + alert("Could not find Style or Lyrics sections. Make sure the response uses the exact format."); + } else { + alert("Title, Style, and Lyrics extracted. Use Copy buttons to paste into Suno."); + } + } + + function copyToClipboard(elementId, label) { + const el = document.getElementById(elementId); + el.select(); + el.setSelectionRange(0, 99999); + navigator.clipboard.writeText(el.value).then(() => alert(label + " copied!")); + } + +
diff --git a/templates/player.html b/templates/player.html index 0a1691e..950357e 100644 --- a/templates/player.html +++ b/templates/player.html @@ -1,68 +1,125 @@ - - -Your Theme Song - + + + Your Theme Song + -
-

🎧 Your Custom Theme Song

-

Hi {{ req.name }}! Listen to both versions and pick the one you want.

+
+

🎧 Your Custom Theme Song

+

Hi {{ req.name }}! Listen to both versions and pick the one you want.

-
-

Version A

- -
+ +
+

Version A

+ +
-
-

Version B

- -
+ +
+

Version B

+ +
-{% if req.status in ['songs_uploaded','awaiting_payment','paid','delivered'] %} -
- -
- - - -
-
+ {% if req.status in ['songs_uploaded','awaiting_payment','paid','delivered'] %} + +
+ +
+ + + +
+
-
- - -
- -
-
-{% endif %} + +
+ + +
+ +
+
+ {% endif %} -{% if req.status == 'awaiting_payment' %} -
Thanks for choosing {{ req.customer_approved.upper() }}! Please head to the booth to finalize payment and collect your files.
-{% endif %} + {% if req.status == 'awaiting_payment' %} + +
+ Thanks for choosing {{ req.customer_approved.upper() }}! Please head to the booth to finalize payment and collect your files. +
+ {% endif %} -{% if req.status == 'delivered' %} -
Delivered! ✅ Check your email for the MP3 attachment(s).
-{% endif %} + {% if req.status == 'delivered' %} + +
+ Delivered! ✅ Check your email for the MP3 attachment(s). +
+ {% endif %} -
+
diff --git a/templates/request.html b/templates/request.html index c31a1e3..71570a0 100644 --- a/templates/request.html +++ b/templates/request.html @@ -1,136 +1,134 @@ - - -Request Your Theme Song - + + + Request Your Theme Song + -
- +
+ + -
-

🎵 Get Your Custom Theme Song 🎶

-

Tell us about yourself and we'll craft a one-of-a-kind song just for you.

+
+

🎵 Get Your Custom Theme Song 🎶

+

Tell us about yourself and we'll craft a one-of-a-kind song just for you.

-
- - + + + - - + + - - + + - - + + - - + + - - + + - -
-

Your info is only used to create and deliver your song.

+ + +

Your info is only used to create and deliver your song.

+
-
diff --git a/templates/thanks.html b/templates/thanks.html index 03c364a..aa9dbf7 100644 --- a/templates/thanks.html +++ b/templates/thanks.html @@ -1,22 +1,44 @@ - - -Request Received - + + + Request Received + -
-

✅ Request Received

-

Thanks, {{ req.name }}! We'll craft your song and email you a link when it's ready.

-

Your request number: #{{ req.id }}

-

Bring this number to the booth if you want to check on progress.

-
+
+

✅ Request Received

+

Thanks, {{ req.name }}! We'll craft your song and email you a link when it's ready.

+

Your request number: #{{ req.id }}

+

Bring this number to the booth if you want to check on progress.

+