This repository has been archived on 2026-09-04. You can view files and clone it, but you cannot make any changes to it's state, such as pushing and creating new issues, pull requests or comments.
theme-song-booth/models.py
Troll (Hermes Agent) 3f9de1449c Fix 500 on customer revision when revision_history table is missing
Deployed databases can persist without the revision_history table,
causing /play/<token>/revise to crash with sqlite3.OperationalError.

- log_revision() now creates the table on the fly if it is missing.
- app.py runs init_db() at import time (safe CREATE TABLE IF NOT EXISTS)
  so new deployments auto-create missing tables on startup.

Bump patch version 0.4.0 -> 0.4.1.
2026-08-05 13:42:10 +00:00

227 lines
7.6 KiB
Python

"""
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, player token,
vocal gender preference, revision count, revision note, and operator notes.
- Revisions: when a customer requests changes, the current A/B MP3 files are
renamed to archived "RevN-" copies and new versions are uploaded later.
- operator_notes is an internal column for the booth team and is never shown
to customers.
- 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,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
name TEXT NOT NULL,
email TEXT NOT NULL,
hobbies TEXT,
notable_facts TEXT,
style_genre TEXT,
extra_requests TEXT,
status TEXT DEFAULT 'pending',
suno_title TEXT,
suno_style TEXT,
suno_lyrics TEXT,
song_a_path TEXT,
song_b_path TEXT,
vocal_gender TEXT,
customer_approved TEXT DEFAULT 'none',
approval_notified_at TIMESTAMP,
preview_sent_at TIMESTAMP,
delivery_sent_at TIMESTAMP,
square_payment_ref TEXT,
admin_alert_email TEXT, -- reserved for future operator alerts; currently unused
player_token TEXT NOT NULL UNIQUE,
revision_count INTEGER DEFAULT 0,
revision_note TEXT,
operator_notes TEXT,
stems_link TEXT
);
CREATE INDEX IF NOT EXISTS idx_requests_status ON requests(status);
CREATE INDEX IF NOT EXISTS idx_requests_token ON requests(player_token);
CREATE TABLE IF NOT EXISTS revision_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
revision_count INTEGER NOT NULL,
note TEXT,
old_song_a_path TEXT,
old_song_b_path TEXT,
new_song_a_path TEXT,
new_song_b_path TEXT,
FOREIGN KEY (request_id) REFERENCES requests(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_revision_history_request ON revision_history(request_id);
"""
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, vocal_gender=None):
"""
Insert a new customer request.
Returns the auto-generated request id.
"""
db = get_db()
cur = db.execute(
"""INSERT INTO requests
(name, email, hobbies, notable_facts, style_genre, extra_requests, vocal_gender, player_token)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(name, email, hobbies, notable_facts, style_genre, extra_requests, vocal_gender, new_token())
)
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.
Normalizes whitespace in the status parameter so URLs like "Needs Upload"
match the stored value."""
db = get_db()
if status:
# Translate human filter names to stored status values.
status = status.lower().replace(' ', '_')
rows = db.execute('SELECT * FROM requests WHERE status = ? ORDER BY created_at DESC', (status,)).fetchall()
else:
rows = db.execute('SELECT * FROM requests ORDER BY created_at DESC').fetchall()
return [dict(r) for r in rows]
def get_requests_by_email(email):
"""Fetch all requests for a given email address, newest first.
Email is compared case-insensitively and stripped of whitespace."""
db = get_db()
rows = db.execute(
"SELECT * FROM requests WHERE LOWER(TRIM(email)) = LOWER(TRIM(?)) ORDER BY created_at DESC",
(email,)
).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()
cols = ', '.join(f'{k} = ?' for k in fields)
vals = list(fields.values()) + [request_id]
db.execute(f'UPDATE requests SET {cols} WHERE id = ?', vals)
db.commit()
def delete_request(request_id):
"""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.execute('DELETE FROM requests WHERE id = ?', (request_id,))
db.commit()
def log_revision(request_id, revision_count, note, old_a=None, old_b=None, new_a=None, new_b=None):
"""Record a revision event in the revision_history table, creating the table if it is missing."""
db = get_db()
try:
db.execute(
"""INSERT INTO revision_history
(request_id, revision_count, note, old_song_a_path, old_song_b_path, new_song_a_path, new_song_b_path)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(request_id, revision_count, note, old_a, old_b, new_a, new_b)
)
db.commit()
except sqlite3.OperationalError as e:
if 'no such table' in str(e):
# Schema drift: table missing. Recreate and retry once.
db.executescript(SCHEMA)
db.execute(
"""INSERT INTO revision_history
(request_id, revision_count, note, old_song_a_path, old_song_b_path, new_song_a_path, new_song_b_path)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(request_id, revision_count, note, old_a, old_b, new_a, new_b)
)
db.commit()
else:
raise
def list_revision_history(request_id):
"""Return all revision history rows for a request, oldest first."""
db = get_db()
rows = db.execute(
'SELECT * FROM revision_history WHERE request_id = ? ORDER BY created_at ASC',
(request_id,)
).fetchall()
return [dict(r) for r in rows]
def reset_all_requests():
"""Delete every row in the requests table and reset id auto-increment."""
db = get_db()
db.execute('DELETE FROM requests')
db.execute('DELETE FROM sqlite_sequence WHERE name = ?', ('requests',))
db.commit()