""" 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/settings -> health check, DB stats, disk usage, system reset - /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 import ssl import time from email.message import EmailMessage from pathlib import Path import json import time from cryptography.fernet import Fernet from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC import base64 # Flask and related imports from flask import Flask, request, render_template, redirect, url_for, flash, session, send_from_directory, abort, current_app, send_file from flask_limiter import Limiter from flask_limiter.util import get_remote_address 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, get_db from models import SCHEMA # Audio metadata import from mutagen.mp3 import MP3 from mutagen.easyid3 import EasyID3 # --------------------------------------------------------------------------- # App setup # --------------------------------------------------------------------------- # Create the Flask app and load configuration from Config class. app = Flask(__name__) app.config.from_object(Config) # Request rate limiting: by remote IP. Defaults can be overridden via Limiter storage when configured. limiter = Limiter(get_remote_address, app=app, default_limits=["60 per minute"]) # 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', 'songs_uploaded': 'Songs Uploaded — Awaiting Approval', 'revisions_requested': 'Revisions Requested', 'awaiting_payment': 'Awaiting Payment', 'paid': 'Paid', 'delivered': 'Delivered', } # --------------------------------------------------------------------------- # 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, song_title=None): """ Save an uploaded MP3 file for a request, preserving the original filename with a version prefix (e.g. A - MySong.mp3 / B - MySong.mp3). Applies the configured metadata tags (title, artist, album, year, comment). """ if not file_obj or file_obj.filename == '': return None if not allowed_file(file_obj.filename): flash('Only MP3 files are allowed.', 'error') return None original = secure_filename(file_obj.filename) filename = f"{version.upper()} - {original}" p = upload_path(request_id) dest = p / filename file_obj.save(dest) apply_mp3_tags(str(dest), song_title) return str(dest) def apply_mp3_tags(path, title=None): """ Write or overwrite common ID3 tags on an MP3 file using values from runtime booth settings. The saved Suno title is written to the Title tag. """ cfg = load_booth_settings() try: audio = MP3(path) if audio.tags is None: audio.add_tags() if not isinstance(audio.tags, EasyID3): audio.tags = EasyID3() tags = audio.tags if title: tags['title'] = title if cfg.get('artist'): tags['artist'] = cfg['artist'] if cfg.get('album'): tags['album'] = cfg['album'] if cfg.get('year'): tags['date'] = str(cfg['year']) audio.save() if cfg.get('comment'): from mutagen.id3 import COMM, TXXX audio2 = MP3(path) if audio2.tags is None: audio2.add_tags() audio2.tags["COMM"] = COMM(encoding=3, lang='eng', desc='Comment', text=cfg['comment']) audio2.tags["TXXX:Comment"] = TXXX(encoding=3, desc='Comment', text=cfg['comment']) audio2.save() except Exception as e: try: flash(f'Warning: could not tag MP3: {e}', 'error') except RuntimeError: import logging logging.getLogger('app').warning('Could not tag MP3 %s: %s', path, e) def _get_fernet(): """Derive a Fernet key from the Flask SECRET_KEY so stored values are encrypted.""" secret = current_app.config['SECRET_KEY'].encode() kdf = PBKDF2HMAC( algorithm=hashes.SHA256(), length=32, salt=b'theme-song-booth-v1', iterations=480000, ) key = base64.urlsafe_b64encode(kdf.derive(secret)) return Fernet(key) def encrypt_value(value): """Encrypt a string using the Flask SECRET_KEY. Returns base64 ciphertext.""" if not value: return '' return _get_fernet().encrypt(value.encode()).decode() def decrypt_value(ciphertext): """Decrypt a string previously encrypted by encrypt_value.""" if not ciphertext: return '' try: return _get_fernet().decrypt(ciphertext.encode()).decode() except Exception: return '' def settings_file_path(): """Return the path to the persistent runtime settings JSON file.""" return Path(current_app.config['UPLOAD_FOLDER']).parent / current_app.config['SETTINGS_FILE'] def load_booth_settings(): """Load persistent runtime settings from JSON file inside the upload parent.""" cfg_path = settings_file_path() if cfg_path.exists(): try: return json.loads(cfg_path.read_text()) except (json.JSONDecodeError, OSError): pass return {} def save_booth_settings(settings): """Persist runtime settings to JSON file.""" cfg_path = settings_file_path() try: cfg_path.write_text(json.dumps(settings, indent=2)) except OSError as e: flash(f'Warning: could not save settings: {e}', 'error') def get_email_config(): """ Return the effective SMTP configuration. Runtime-encrypted settings from disk override env defaults. """ cfg = load_booth_settings() return { 'SMTP_HOST': cfg.get('smtp_host', current_app.config['SMTP_HOST']), 'SMTP_PORT': int(cfg.get('smtp_port') or current_app.config['SMTP_PORT']), 'SMTP_USER': cfg.get('smtp_user', current_app.config['SMTP_USER']), 'SMTP_PASS': decrypt_value(cfg.get('smtp_pass', '')) or current_app.config['SMTP_PASS'], 'SMTP_FROM': cfg.get('smtp_from', current_app.config['SMTP_FROM']), } def get_refresh_seconds(): """Return the dashboard auto-refresh interval in seconds (10, 20, or 30).""" cfg = load_booth_settings() try: val = int(cfg.get('refresh_seconds', 10)) except (ValueError, TypeError): val = 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): """Send an email using the configured or runtime SMTP settings.""" cfg = get_email_config() if not cfg['SMTP_PASS']: raise RuntimeError('SMTP password is not configured') msg = EmailMessage() msg['From'] = cfg['SMTP_FROM'] msg['To'] = to msg['Subject'] = subject msg.set_content(body) html_body = body.replace('\n', '
\n') if inline_images: for _, cid in inline_images: html_body += f'
Dionysis Media' html_body += f'


Dionysis Media: stories, sound, and a little divine chaos — https://dionysismedia.ca/

' msg.add_alternative(html_body, subtype='html') if inline_images: for path, cid in inline_images: with open(path, 'rb') as f: data = f.read() ext = Path(path).suffix.lower().lstrip('.') subtype = ext if ext in ('png', 'jpg', 'jpeg', 'gif', 'webp') else 'png' msg.get_payload()[1].add_related(data, maintype='image', subtype=subtype, cid=f'<{cid}>') if attachments: for path, name in attachments: with open(path, 'rb') as f: data = f.read() msg.add_attachment(data, maintype='audio', subtype='mpeg', filename=name) with smtplib.SMTP_SSL(cfg['SMTP_HOST'], cfg['SMTP_PORT'], context=ssl.create_default_context()) as server: server.login(cfg['SMTP_USER'], cfg['SMTP_PASS']) server.send_message(msg) def build_signature_images(): """ Return inline image tuple list for the Dionysis Media logo if present. The logo path is configurable via /admin/settings. A downscaled, compressed email-sized JPEG copy is generated in the upload folder so attachments stay small. :return: list of (path, cid) tuples. Empty if no logo is configured or not found. """ cfg = load_booth_settings() logo_path = cfg.get('logo_path') or '/mnt/Storage/DM-Logo.png' logo_file = Path(logo_path) if not logo_file.exists(): return [] # Cache a small email-friendly JPEG inside the upload folder. cache_dir = Path(current_app.config['UPLOAD_FOLDER']) cache_dir.mkdir(parents=True, exist_ok=True) email_logo = cache_dir / 'dm-logo.email.jpg' try: from PIL import Image with Image.open(logo_file) as im: im.thumbnail((600, 600)) if im.mode in ('RGBA', 'LA', 'P'): # Composite transparent images onto a white background for JPEG. background = Image.new('RGB', im.size, (255, 255, 255)) if im.mode == 'P': im = im.convert('RGBA') background.paste(im, mask=im.split()[-1] if im.mode == 'RGBA' else None) im = background else: im = im.convert('RGB') im.save(email_logo, format='JPEG', optimize=True, quality=85) except Exception: # If resize fails, fall back to the original file. return [(str(logo_file), 'dm-logo')] return [(str(email_logo), 'dm-logo')] # --------------------------------------------------------------------------- # Public customer routes # --------------------------------------------------------------------------- @app.route('/request', methods=['GET', 'POST']) @limiter.limit("5 per minute") 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(), email=request.form.get('email', '').strip(), hobbies=request.form.get('hobbies', '').strip(), notable_facts=request.form.get('notable_facts', '').strip(), style_genre=request.form.get('style_genre', '').strip(), extra_requests=request.form.get('extra_requests', '').strip(), vocal_gender=request.form.get('vocal_gender', '').strip(), ) # Send confirmation email with a summary of what the customer asked for. req = get_request_by_id(rid) if req: try: body_lines = [ f"Hi {req['name']},", "", "Thanks for stopping by the Trollgorithm Theme Song Booth! We've received your request and will start crafting your custom song soon.", "", "Here's what we have on file:", f"Name: {req['name']}", f"Email: {req['email']}", f"Style / genre: {req['style_genre'] or '-'}", f"Preferred singer voice / gender: {req['vocal_gender'] or 'No preference'}", f"Hobbies: {req['hobbies'] or '-'}", f"Notable facts: {req['notable_facts'] or '-'}", f"Extra requests: {req['extra_requests'] or '-'}", "", "You'll get another email with a private link to preview two versions of your song when they're ready.", "", "— Trollgorithm / Dionysis Media" ] send_email(req['email'], 'Your theme song request is received', '\n'.join(body_lines), inline_images=build_signature_images()) except Exception as e: flash(f'Your request was saved, but we could not send a confirmation email: {e}', 'error') flash('Your request has been submitted! Check your email soon.', 'success') 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) # Load runtime max revisions setting. runtime_settings = load_booth_settings() max_revisions = runtime_settings.get('max_revisions', current_app.config['MAX_REVISIONS']) revisions_left = max(0, max_revisions - (req.get('revision_count') or 0)) return render_template('player.html', req=req, revisions_left=revisions_left) @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) choice = request.form.get('choice') if choice not in ('a', 'b', 'both'): flash('Invalid selection.', 'error') return redirect(url_for('play', token=token)) 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') 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. """ note = request.form.get('revision_note', '').strip() req = get_request_by_token(token) if not req: abort(404) # Enforce max revisions limit for customer-submitted revisions. runtime_settings = load_booth_settings() max_revisions = runtime_settings.get('max_revisions', current_app.config['MAX_REVISIONS']) current_count = req.get('revision_count') or 0 if current_count >= max_revisions: flash('Revision limit reached. Please speak to the booth operator if you need further changes.', 'error') return redirect(url_for('play', token=token)) # Increment revision counter and archive current files before new versions are uploaded. new_count = current_count + 1 upload_dir = Path(current_app.config['UPLOAD_FOLDER']) / str(req['id']) if upload_dir.exists(): for field, version in (('song_a_path', 'A'), ('song_b_path', 'B')): path = req.get(field) if path and Path(path).exists(): old = Path(path) archived = upload_dir / f"Rev{new_count}-{old.name}" try: old.rename(archived) req[field] = str(archived) except OSError: pass update_request(req['id'], revision_note=note, status='revisions_requested', song_a_path=req.get('song_a_path'), song_b_path=req.get('song_b_path'), customer_approved='none', revision_count=new_count) # 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) if version not in ('a', 'b'): abort(404) field = f'song_{version}_path' path = req.get(field) if not path or not Path(path).exists(): abort(404) return send_from_directory(Path(path).parent, Path(path).name) # --------------------------------------------------------------------------- # 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': if admin_password_ok(request.form.get('password', '')): session['admin'] = True return redirect(url_for('admin_dashboard')) 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 status_filter = request.args.get('status') requests = list_requests(status_filter) return render_template('admin/dashboard.html', requests=requests, statuses=STATUS_LABELS, current_status=status_filter, refresh_seconds=get_refresh_seconds()) @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 req = get_request_by_id(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()) def basename(path): return Path(path).name if path else '' # Collect any extra MP3 files in the request folder (archived revisions). upload_dir = Path(current_app.config['UPLOAD_FOLDER']) / str(rid) current_paths = {req['song_a_path'], req['song_b_path']} extra_files = [] if upload_dir.exists(): for f in upload_dir.iterdir(): if f.is_file() and f.suffix.lower() == '.mp3' and str(f) not in current_paths: extra_files.append(str(f)) extra_files.sort() if request.method == 'POST': 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(), suno_lyrics=request.form.get('suno_lyrics', '').strip(), status='prompt_ready' ) flash('Prompt saved.', 'success') elif action == 'upload_songs': # Save uploaded MP3 files for Version A and/or Version B. # Use the saved Suno title as the MP3 title tag if available. song_title = req.get('suno_title') or None a_path = save_upload(rid, request.files.get('song_a'), 'a', song_title) b_path = save_upload(rid, request.files.get('song_b'), 'b', song_title) fields = {} if a_path: fields['song_a_path'] = a_path if b_path: fields['song_b_path'] = b_path if fields: fields['status'] = 'songs_uploaded' update_request(rid, **fields) 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: player_link = f"{current_app.config['PUBLIC_BASE_URL']}/play/{req['player_token']}" body = f"Hi {req['name']},\n\nYour custom theme song has been created. Listen to both versions and let us know which one you want:\n\n{player_link}\n\n- Version A\n- Version B\n- Or both versions\n\nOnce you make your choice, we'll send you to the booth to finalize payment and deliver your files.\n\nThanks for stopping by!\n\n— {current_app.config['BOOTH_NAME']}" try: send_email(req['email'], 'Your custom theme song is ready — listen and pick your version', body, inline_images=build_signature_images()) update_request(rid, preview_sent_at=now_utc(), status='songs_uploaded') flash('Preview email sent.', 'success') except Exception as e: flash(f'Failed to send preview email: {e}', 'error') elif action == 'mark_paid_deliver': # Finalize: record Square payment ref, attach approved MP3s, email customer. payment_ref = request.form.get('square_payment_ref', '').strip() if not payment_ref: flash('Square payment reference is required.', 'error') return redirect(url_for('admin_request', rid=rid)) # Build list of selected files from checkboxes. selected = request.form.getlist('deliver_file') if not selected: flash('Select at least one file to deliver.', 'error') return redirect(url_for('admin_request', rid=rid)) attachments = [] for path in selected: p = Path(path) if p.exists(): attachments.append((str(p), p.name)) player_link = f"{current_app.config['PUBLIC_BASE_URL']}/play/{req['player_token']}" body = f"Hi {req['name']},\n\nThanks for your payment! Your selected song(s) are attached to this email.\n\nYou can also keep streaming them here: {player_link}\n\nEnjoy!\n\n— {current_app.config['BOOTH_NAME']}" try: send_email(req['email'], 'Your theme song files are here!', body, attachments=attachments, inline_images=build_signature_images()) update_request(rid, square_payment_ref=payment_ref, delivery_sent_at=now_utc(), status='delivered') flash('Delivery email sent with MP3 attachments.', 'success') except Exception as e: flash(f'Failed to send delivery email: {e}', 'error') return redirect(url_for('admin_request', rid=rid)) return render_template('admin/request.html', req=req, statuses=STATUS_LABELS, file_exists=file_exists, basename=basename, extra_files=extra_files) @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 req = get_request_by_id(rid) if not req: abort(404) # 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(): try: Path(path).unlink() except OSError: pass # Remove empty upload directory. upload_dir = Path(current_app.config['UPLOAD_FOLDER']) / str(rid) if upload_dir.exists(): try: upload_dir.rmdir() except OSError: pass delete_request(rid) flash(f'Request #{rid} deleted.', 'success') return redirect(url_for('admin_dashboard')) @app.route('/admin/settings', methods=['GET', 'POST']) def admin_settings(): """ Settings / maintenance page for operators. GET -> show database health, statistics, disk usage, and reset button. POST -> either run a health check/fix or reset the system. """ redir = require_admin() if redir: return redir db_path = Path(current_app.config['DATABASE']) upload_root = Path(current_app.config['UPLOAD_FOLDER']) # Compute database stats. db_size = db_path.stat().st_size if db_path.exists() else 0 all_requests = list_requests() total_records = len(all_requests) status_counts = {} for req in all_requests: status_counts[req['status']] = status_counts.get(req['status'], 0) + 1 # Load persistent runtime settings (max_revisions overrides env var if set). runtime_settings = load_booth_settings() current_max_revisions = runtime_settings.get('max_revisions', current_app.config.get('MAX_REVISIONS', 2)) current_refresh_seconds = runtime_settings.get('refresh_seconds', 10) # Effective email config to show in the form (non-sensitive only; password left blank). email_form = { 'smtp_host': runtime_settings.get('smtp_host', current_app.config['SMTP_HOST']), 'smtp_port': runtime_settings.get('smtp_port', str(current_app.config['SMTP_PORT'])), 'smtp_user': runtime_settings.get('smtp_user', current_app.config['SMTP_USER']), 'smtp_from': runtime_settings.get('smtp_from', current_app.config['SMTP_FROM']), 'smtp_pass_set': bool(runtime_settings.get('smtp_pass', '')), 'logo_path': runtime_settings.get('logo_path', '/mnt/Storage/DM-Logo.png'), } # Compute upload folder stats. total_upload_size = 0 upload_file_count = 0 request_dir_count = 0 if upload_root.exists(): for entry in upload_root.iterdir(): if entry.is_dir(): request_dir_count += 1 for f in entry.iterdir(): if f.is_file(): total_upload_size += f.stat().st_size upload_file_count += 1 elif entry.is_file(): total_upload_size += entry.stat().st_size upload_file_count += 1 def format_bytes(n): for unit in ['B', 'KB', 'MB', 'GB']: if n < 1024: return f"{n:.2f} {unit}" n /= 1024 return f"{n:.2f} TB" # Health check: verify expected columns exist. expected_cols = { 'id', 'created_at', 'name', 'email', 'hobbies', 'notable_facts', 'style_genre', 'extra_requests', 'vocal_gender', 'status', 'suno_title', 'suno_style', 'suno_lyrics', 'song_a_path', 'song_b_path', 'customer_approved', 'approval_notified_at', 'preview_sent_at', 'delivery_sent_at', 'square_payment_ref', 'admin_alert_email', 'player_token', 'revision_note', 'revision_count' } health = {'ok': True, 'missing_columns': [], 'message': 'Database schema looks good.'} try: db = get_db() cur = db.execute('PRAGMA table_info(requests)') existing_cols = {row['name'] for row in cur.fetchall()} missing = sorted(expected_cols - existing_cols) if missing: health = {'ok': False, 'missing_columns': missing, 'message': f'Missing columns: {", ".join(missing)}'} except Exception as e: health = {'ok': False, 'missing_columns': [], 'message': f'Could not inspect table: {e}'} if request.method == 'POST': action = request.form.get('action') if action == 'fix_db': # Attempt to add missing columns via ALTER TABLE. if not health['ok'] and health['missing_columns']: try: db = get_db() for col in health['missing_columns']: # Default to TEXT columns; adequate for current schema. db.execute(f'ALTER TABLE requests ADD COLUMN {col} TEXT') db.commit() flash(f'Added missing columns: {", ".join(health["missing_columns"])}. Please refresh the page.', 'success') except Exception as e: flash(f'Failed to fix database: {e}', 'error') else: flash('No columns need fixing.', 'success') return redirect(url_for('admin_settings')) elif action == 'reset_system': # Same nuclear reset logic as the old /admin/reset endpoint. if upload_root.exists(): for entry in upload_root.iterdir(): try: if entry.is_file(): entry.unlink() elif entry.is_dir(): shutil.rmtree(entry) except OSError: pass reset_all_requests() flash('System reset complete. All orders and files have been cleared.', 'success') return redirect(url_for('admin_settings')) elif action == 'save_max_revisions': # Update the MAX_REVISIONS config from the settings form. try: val = int(request.form.get('max_revisions', '2').strip()) if val < 0: raise ValueError cfg = load_booth_settings() cfg['max_revisions'] = val save_booth_settings(cfg) flash(f'Maximum revisions set to {val}.', 'success') except ValueError: flash('Invalid revision limit. Please enter a non-negative number.', 'error') return redirect(url_for('admin_settings')) elif action == 'save_metadata': # Update MP3 metadata defaults from the settings form. cfg = load_booth_settings() cfg['artist'] = request.form.get('artist', '').strip() or None cfg['album'] = request.form.get('album', '').strip() or None cfg['year'] = request.form.get('year', '').strip() or None cfg['comment'] = request.form.get('comment', '').strip() or None save_booth_settings(cfg) flash('MP3 metadata defaults saved.', 'success') return redirect(url_for('admin_settings')) elif action == 'save_email_config': # Update SMTP settings from the settings form. Password is encrypted. cfg = load_booth_settings() cfg['smtp_host'] = request.form.get('smtp_host', '').strip() or None cfg['smtp_port'] = request.form.get('smtp_port', '').strip() or None cfg['smtp_user'] = request.form.get('smtp_user', '').strip() or None cfg['smtp_from'] = request.form.get('smtp_from', '').strip() or None cfg['logo_path'] = request.form.get('logo_path', '').strip() or None new_pass = request.form.get('smtp_pass', '').strip() # Only overwrite the stored password if a new value was provided. if new_pass: cfg['smtp_pass'] = encrypt_value(new_pass) save_booth_settings(cfg) flash('Email (SMTP) settings saved. Password stored encrypted.', 'success') return redirect(url_for('admin_settings')) elif action == 'send_test_email': # Send a test email to the address provided in the form. test_to = request.form.get('test_email_address', '').strip() if not test_to: flash('Enter a test email address first.', 'error') return redirect(url_for('admin_settings')) try: body = "Hi,\n\nThis is a test email from the Trollgorithm Theme Song Booth. If you're seeing this, SMTP is configured correctly." send_email(test_to, 'SMTP Test from Theme Song Booth', body, inline_images=build_signature_images()) flash(f'Test email sent to {test_to}.', 'success') except Exception as e: flash(f'Failed to send test email: {e}', 'error') return redirect(url_for('admin_settings')) elif action == 'save_refresh': # Update dashboard auto-refresh interval. val = request.form.get('refresh_seconds', '10').strip() if val not in ('0', '10', '20', '30'): val = '10' cfg = load_booth_settings() cfg['refresh_seconds'] = int(val) save_booth_settings(cfg) flash(f'Dashboard auto-refresh set to {val} seconds.', 'success') return redirect(url_for('admin_settings')) elif action == 'download_db': # Send the SQLite database file as a download. if db_path.exists(): return send_file(str(db_path), as_attachment=True, download_name='theme-song-booth.db') flash('Database file not found.', 'error') return redirect(url_for('admin_settings')) elif action == 'restore_db': # Replace the current database file with an uploaded SQLite backup. file_obj = request.files.get('db_backup') if not file_obj or file_obj.filename == '': flash('No database backup file selected.', 'error') return redirect(url_for('admin_settings')) backup_path = db_path.with_suffix('.backup-restore') try: # Stream uploaded file directly to disk to avoid memory issues with large DBs. file_obj.save(backup_path) # Quick sanity check: try to open as SQLite and query sqlite_master. import sqlite3 conn = sqlite3.connect(str(backup_path)) conn.execute("SELECT name FROM sqlite_master WHERE type='table'") conn.close() # Replace old database with backup. old_backup = db_path.with_suffix('.backup-' + str(int(time.time()))) db_path.rename(old_backup) backup_path.rename(db_path) flash('Database restored successfully. Old database kept at ' + old_backup.name, 'success') except Exception as e: if backup_path.exists(): backup_path.unlink() flash(f'Database restore failed: {e}', 'error') return redirect(url_for('admin_settings')) return render_template( 'admin/settings.html', health=health, db_size=format_bytes(db_size), total_records=total_records, status_counts=status_counts, statuses=STATUS_LABELS, upload_file_count=upload_file_count, upload_dir_count=request_dir_count, upload_size=format_bytes(total_upload_size), db_path=str(db_path), upload_path=str(upload_root), current_max_revisions=current_max_revisions, current_refresh_seconds=current_refresh_seconds, email_form=email_form, metadata=runtime_settings, ) @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 upload_root = Path(current_app.config['UPLOAD_FOLDER']) if upload_root.exists(): for entry in upload_root.iterdir(): try: if entry.is_file(): entry.unlink() elif entry.is_dir(): shutil.rmtree(entry) except OSError: pass reset_all_requests() flash('System reset complete. All orders and files have been cleared.', 'success') return redirect(url_for('admin_dashboard')) # --------------------------------------------------------------------------- # 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')