""" 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 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', 'songs_uploaded': 'Songs Uploaded — Awaiting Approval', '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): """ Save an uploaded MP3 file for a request, preserving the original filename with a version prefix (e.g. A - MySong.mp3 / B - MySong.mp3). :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): flash('Only MP3 files are allowed.', 'error') return None # Use Werkzeug's secure_filename to strip unsafe characters. original = secure_filename(file_obj.filename) # Prefix with A or B so the operator knows which version it is. filename = f"{version.upper()} - {original}" p = upload_path(request_id) dest = p / filename file_obj.save(dest) return str(dest) 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') msg = EmailMessage() msg['From'] = cfg['SMTP_FROM'] msg['To'] = to 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: 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) # --------------------------------------------------------------------------- # 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(), 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(), ) 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) 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) 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. """ 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') # 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) @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 '' 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. a_path = save_upload(rid, request.files.get('song_a'), 'a') b_path = save_upload(rid, request.files.get('song_b'), 'b') 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) 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. if req['customer_approved'] == 'none': flash('Customer has not approved a version yet.', 'error') else: 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)) attachments = [] if req['customer_approved'] in ('a', 'both') and req['song_a_path']: a_name = Path(req['song_a_path']).name attachments.append((req['song_a_path'], a_name)) if req['customer_approved'] in ('b', 'both') and req['song_b_path']: b_name = Path(req['song_b_path']).name attachments.append((req['song_b_path'], b_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 approved song is attached to this email.\n\nIf you selected both versions, you'll find two MP3 files.\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) 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) @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/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')