Initial prototype for theme song booth
This commit is contained in:
commit
c16d48ee32
16 changed files with 898 additions and 0 deletions
285
app.py
Normal file
285
app.py
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
import os
|
||||
import smtplib
|
||||
import ssl
|
||||
from email.message import EmailMessage
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, request, render_template, redirect, url_for, flash, session, send_from_directory, abort, current_app
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
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
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(Config)
|
||||
app.teardown_appcontext(close_db)
|
||||
|
||||
STATUS_LABELS = {
|
||||
'pending': 'Pending',
|
||||
'prompt_ready': 'Prompt Ready',
|
||||
'songs_uploaded': 'Songs Uploaded — Awaiting Approval',
|
||||
'awaiting_payment': 'Awaiting Payment',
|
||||
'paid': 'Paid',
|
||||
'delivered': 'Delivered',
|
||||
}
|
||||
|
||||
# ---------------- helpers ----------------
|
||||
|
||||
def is_admin():
|
||||
return session.get('admin') is True
|
||||
|
||||
def require_admin():
|
||||
if not is_admin():
|
||||
return redirect(url_for('admin_login'))
|
||||
|
||||
def allowed_file(filename):
|
||||
return '.' in filename and filename.rsplit('.', 1)[1].lower() in current_app.config['ALLOWED_EXTENSIONS']
|
||||
|
||||
def upload_path(request_id):
|
||||
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):
|
||||
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
|
||||
p = upload_path(request_id)
|
||||
filename = f'song_{version}.mp3'
|
||||
file_obj.save(p / filename)
|
||||
return str(p / filename)
|
||||
|
||||
def send_email(to, subject, body, attachments=None):
|
||||
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)
|
||||
|
||||
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 ----------------
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return redirect(url_for('request_form'))
|
||||
|
||||
@app.route('/request', methods=['GET', 'POST'])
|
||||
def request_form():
|
||||
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/<int:rid>')
|
||||
def thanks(rid):
|
||||
req = get_request_by_id(rid)
|
||||
if not req:
|
||||
abort(404)
|
||||
return render_template('thanks.html', req=req)
|
||||
|
||||
@app.route('/play/<token>')
|
||||
def play(token):
|
||||
req = get_request_by_token(token)
|
||||
if not req:
|
||||
abort(404)
|
||||
return render_template('player.html', req=req)
|
||||
|
||||
@app.route('/play/<token>/approve', methods=['POST'])
|
||||
def approve(token):
|
||||
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())
|
||||
|
||||
# alert operator
|
||||
alert_to = current_app.config['ADMIN_ALERT_EMAIL'] or current_app.config['SMTP_FROM']
|
||||
if alert_to:
|
||||
admin_link = f"{current_app.config['PUBLIC_BASE_URL']}/admin/request/{req['id']}"
|
||||
version_label = {'a': 'A', 'b': 'B', 'both': 'Both'}[choice]
|
||||
body = f"{req['name']} ({req['email']}) approved: Version {version_label}.\n\nRequest #{req['id']}\nPayment is now due.\n\nOpen admin: {admin_link}"
|
||||
try:
|
||||
send_email(alert_to, f"{req['name']} approved their theme song", body)
|
||||
except Exception as e:
|
||||
flash(f'Approval saved, but operator alert failed: {e}', 'warning')
|
||||
return redirect(url_for('play', token=token))
|
||||
|
||||
flash('Thanks! Please return to the booth to finalize payment.', 'success')
|
||||
return redirect(url_for('play', token=token))
|
||||
|
||||
@app.route('/play/<token>/revise', methods=['POST'])
|
||||
def revise(token):
|
||||
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')
|
||||
|
||||
alert_to = current_app.config['ADMIN_ALERT_EMAIL'] or current_app.config['SMTP_FROM']
|
||||
if alert_to and note:
|
||||
admin_link = f"{current_app.config['PUBLIC_BASE_URL']}/admin/request/{req['id']}"
|
||||
body = f"{req['name']} ({req['email']}) requested changes for request #{req['id']}.\n\nNote:\n{note}\n\nOpen admin: {admin_link}"
|
||||
try:
|
||||
send_email(alert_to, f"{req['name']} requested changes", body)
|
||||
except Exception as e:
|
||||
flash(f'Revision saved, but operator alert failed: {e}', 'warning')
|
||||
return redirect(url_for('play', token=token))
|
||||
|
||||
flash('Your feedback has been sent. We will regenerate and update you.', 'success')
|
||||
return redirect(url_for('play', token=token))
|
||||
|
||||
@app.route('/audio/<token>/<version>.mp3')
|
||||
def audio(token, version):
|
||||
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 ----------------
|
||||
|
||||
@app.route('/admin/login', methods=['GET', 'POST'])
|
||||
def admin_login():
|
||||
if is_admin():
|
||||
return redirect(url_for('admin_dashboard'))
|
||||
if request.method == 'POST':
|
||||
pw_hash = current_app.config['ADMIN_PASSWORD_HASH']
|
||||
if not pw_hash:
|
||||
flash('Admin password is not configured.', 'error')
|
||||
elif check_password_hash(pw_hash, request.form.get('password', '')):
|
||||
session['admin'] = True
|
||||
return redirect(url_for('admin_dashboard'))
|
||||
else:
|
||||
flash('Invalid password.', 'error')
|
||||
return render_template('admin/login.html')
|
||||
|
||||
@app.route('/admin/logout')
|
||||
def admin_logout():
|
||||
session.pop('admin', None)
|
||||
return redirect(url_for('admin_login'))
|
||||
|
||||
@app.route('/admin')
|
||||
def admin_dashboard():
|
||||
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/<int:rid>', methods=['GET', 'POST'])
|
||||
def admin_request(rid):
|
||||
redir = require_admin()
|
||||
if redir:
|
||||
return redir
|
||||
req = get_request_by_id(rid)
|
||||
if not req:
|
||||
abort(404)
|
||||
|
||||
if request.method == 'POST':
|
||||
action = request.form.get('action')
|
||||
|
||||
if action == 'save_prompt':
|
||||
update_request(rid,
|
||||
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':
|
||||
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':
|
||||
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':
|
||||
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']:
|
||||
attachments.append((req['song_a_path'], 'song_a.mp3'))
|
||||
if req['customer_approved'] in ('b', 'both') and req['song_b_path']:
|
||||
attachments.append((req['song_b_path'], 'song_b.mp3'))
|
||||
|
||||
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)
|
||||
|
||||
# ---------------- init ----------------
|
||||
|
||||
@app.cli.command('init-db')
|
||||
def init_db_command():
|
||||
init_db()
|
||||
print('Database initialized.')
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True, host='0.0.0.0')
|
||||
Reference in a new issue