""" 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 pathlib import Path 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, pronouns 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, adding any missing columns to existing tables.""" db_path = current_app.config['DATABASE'] Path(db_path).parent.mkdir(parents=True, exist_ok=True) db = sqlite3.connect(db_path) db.row_factory = sqlite3.Row db.executescript(SCHEMA) # SQLite ALTER TABLE is limited; add newer columns if they are missing. expected_columns = { 'requests': [ 'id', 'created_at', 'name', 'email', 'hobbies', 'notable_facts', 'style_genre', 'pronouns', 'extra_requests', 'status', 'suno_title', 'suno_style', 'suno_lyrics', 'song_a_path', 'song_b_path', 'vocal_gender', 'customer_approved', 'approval_notified_at', 'preview_sent_at', 'delivery_sent_at', 'square_payment_ref', 'admin_alert_email', 'player_token', 'revision_count', 'revision_note', 'operator_notes', 'stems_link' ], 'revision_history': [ 'id', 'created_at', 'request_id', 'revision_count', 'note', 'old_song_a_path', 'old_song_b_path', 'new_song_a_path', 'new_song_b_path' ] } for table, columns in expected_columns.items(): existing = {r['name'] for r in db.execute(f"PRAGMA table_info({table})")} for col in columns: if col not in existing: # revision_count must be INTEGER so arithmetic in app.py works. col_type = 'INTEGER' if col == 'revision_count' else 'TEXT' db.execute(f'ALTER TABLE {table} ADD COLUMN {col} {col_type}') 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, pronouns=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, pronouns, extra_requests, vocal_gender, player_token) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", (name, email, hobbies, notable_facts, style_genre, pronouns, 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 it 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. Run init_db to add tables/columns, then retry once. init_db() 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()