Add dashboard auto-refresh, status filters, rate limiting, database backup/restore

This commit is contained in:
Troll (Hermes Agent) 2026-08-01 22:52:32 +00:00
parent f32efc42c5
commit 5ebce2baff
5 changed files with 138 additions and 29 deletions

67
app.py
View file

@ -30,13 +30,16 @@ import os
import shutil import shutil
import smtplib import smtplib
import ssl import ssl
import time
from email.message import EmailMessage from email.message import EmailMessage
from pathlib import Path from pathlib import Path
import json import json
# Flask and related imports # Flask and related imports
from flask import Flask, request, render_template, redirect, url_for, flash, session, send_from_directory, abort, current_app 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 from werkzeug.utils import secure_filename
# Project imports # Project imports
@ -56,6 +59,9 @@ from mutagen.easyid3 import EasyID3
app = Flask(__name__) app = Flask(__name__)
app.config.from_object(Config) 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. # Ensure the SQLite connection is closed at the end of each request.
app.teardown_appcontext(close_db) app.teardown_appcontext(close_db)
@ -189,6 +195,16 @@ def load_booth_settings():
return {} return {}
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): def save_booth_settings(settings):
"""Persist runtime settings to JSON file.""" """Persist runtime settings to JSON file."""
cfg_path = Path(current_app.config['UPLOAD_FOLDER']).parent / 'booth_settings.json' cfg_path = Path(current_app.config['UPLOAD_FOLDER']).parent / 'booth_settings.json'
@ -239,6 +255,7 @@ def index():
@app.route('/request', methods=['GET', 'POST']) @app.route('/request', methods=['GET', 'POST'])
@limiter.limit("5 per minute")
def request_form(): def request_form():
""" """
Public request form. Public request form.
@ -406,7 +423,7 @@ def admin_dashboard():
return redir return redir
status_filter = request.args.get('status') status_filter = request.args.get('status')
requests = list_requests(status_filter) requests = list_requests(status_filter)
return render_template('admin/dashboard.html', requests=requests, statuses=STATUS_LABELS, current_status=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/<int:rid>', methods=['GET', 'POST']) @app.route('/admin/request/<int:rid>', methods=['GET', 'POST'])
@ -671,12 +688,55 @@ def admin_settings():
cfg['artist'] = request.form.get('artist', '').strip() or None cfg['artist'] = request.form.get('artist', '').strip() or None
cfg['album'] = request.form.get('album', '').strip() or None cfg['album'] = request.form.get('album', '').strip() or None
cfg['year'] = request.form.get('year', '').strip() or None cfg['year'] = request.form.get('year', '').strip() or None
cfg['genre'] = request.form.get('genre', '').strip() or None
cfg['comment'] = request.form.get('comment', '').strip() or None cfg['comment'] = request.form.get('comment', '').strip() or None
save_booth_settings(cfg) save_booth_settings(cfg)
flash('MP3 metadata defaults saved.', 'success') flash('MP3 metadata defaults saved.', 'success')
return redirect(url_for('admin_settings')) 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( return render_template(
'admin/settings.html', 'admin/settings.html',
health=health, health=health,
@ -690,6 +750,7 @@ def admin_settings():
db_path=str(db_path), db_path=str(db_path),
upload_path=str(upload_root), upload_path=str(upload_root),
current_max_revisions=current_max_revisions, current_max_revisions=current_max_revisions,
current_refresh_seconds=runtime_settings.get('refresh_seconds', 10),
metadata=runtime_settings, metadata=runtime_settings,
) )

3
booth_settings.json Normal file
View file

@ -0,0 +1,3 @@
{
"refresh_seconds": 20
}

View file

@ -13,3 +13,4 @@ gunicorn
python-dotenv python-dotenv
werkzeug werkzeug
mutagen mutagen
flask-limiter

View file

@ -4,6 +4,9 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Dashboard</title> <title>Admin Dashboard</title>
{% if refresh_seconds and refresh_seconds > 0 %}
<meta http-equiv="refresh" content="{{ refresh_seconds }}">
{% endif %}
<style> <style>
/* /*
Operator dashboard queue. Operator dashboard queue.
@ -19,14 +22,22 @@
line-height:1.5; line-height:1.5;
} }
.container{max-width:1100px;margin:0 auto;} .container{max-width:1100px;margin:0 auto;}
h1{color:#60a5fa;} h1{color:#60a5fa;margin-top:0;}
.filters{margin-bottom:1rem;} .filters{margin-bottom:1rem;display:flex;gap:.5rem;flex-wrap:wrap;align-items:center;}
.filters a{ .filters a{
color:#93c5fd; color:#93c5fd;
text-decoration:none; text-decoration:none;
margin-right:1rem; padding:.35rem .7rem;
border-radius:.4rem;
border:1px solid transparent;
}
.filters a:hover{background:#1f2937;}
.filters a.active{
font-weight:bold;
color:#fff;
background:#2563eb;
border-color:#2563eb;
} }
.filters a.active{font-weight:bold;color:#fff;}
table{ table{
width:100%; width:100%;
border-collapse:collapse; border-collapse:collapse;
@ -74,7 +85,7 @@
} }
.flash.error{background:#450a0a;} .flash.error{background:#450a0a;}
/* Topbar with Reset System button and Log out link */ /* Topbar with Settings and Log out link */
.topbar{ .topbar{
float:right; float:right;
display:flex; display:flex;
@ -82,17 +93,6 @@
align-items:center; align-items:center;
} }
.topbar form{display:inline;} .topbar form{display:inline;}
.topbar button.reset-sm{
background:#dc2626;
color:#fff;
border:none;
border-radius:.4rem;
padding:.4rem .8rem;
font-weight:600;
cursor:pointer;
font-size:.9rem;
}
.topbar button.reset-sm:hover{background:#b91c1c;}
.topbar a{ .topbar a{
color:#93c5fd; color:#93c5fd;
text-decoration:none; text-decoration:none;
@ -101,8 +101,11 @@
color:#10b981; color:#10b981;
} }
/* Hidden legacy reset box (kept CSS class for compatibility, not displayed) */ .refresh-hint{
.reset-box{display:none;} color:#6b7280;
font-size:.85rem;
margin-left:auto;
}
</style> </style>
</head> </head>
<body> <body>
@ -123,10 +126,17 @@
<!-- Status filter links --> <!-- Status filter links -->
<div class="filters"> <div class="filters">
<a href="{{ url_for('admin_dashboard') }}" class="{% if not current_status %}active{% endif %}">All</a> <a href="{{ url_for('admin_dashboard') }}" class="{% if current_status in (None, '') %}active{% endif %}">All</a>
{% for key,label in statuses.items() %} <a href="{{ url_for('admin_dashboard', status='needs_upload') }}" class="{% if current_status == 'needs_upload' %}active{% endif %}">Needs Upload</a>
<a href="{{ url_for('admin_dashboard', status=key) }}" class="{% if current_status == key %}active{% endif %}">{{ label }}</a> <a href="{{ url_for('admin_dashboard', status='awaiting_payment') }}" class="{% if current_status == 'awaiting_payment' %}active{% endif %}">Awaiting Payment</a>
{% endfor %} <a href="{{ url_for('admin_dashboard', status='done') }}" class="{% if current_status == 'done' %}active{% endif %}">Done</a>
<span class="refresh-hint">
{% if refresh_seconds and refresh_seconds > 0 %}
Auto-refresh every {{ refresh_seconds }}s
{% else %}
Auto-refresh off
{% endif %}
</span>
</div> </div>
<!-- Requests table --> <!-- Requests table -->

View file

@ -218,6 +218,22 @@
</form> </form>
</div> </div>
<!-- Auto refresh -->
<div class="section">
<h2>Dashboard Auto-Refresh</h2>
<form method="POST">
<input type="hidden" name="action" value="save_refresh">
<label for="refresh_seconds">Refresh interval</label>
<select id="refresh_seconds" name="refresh_seconds">
<option value="0" {% if current_refresh_seconds == 0 %}selected{% endif %}>Off</option>
<option value="10" {% if current_refresh_seconds == 10 %}selected{% endif %}>10 seconds</option>
<option value="20" {% if current_refresh_seconds == 20 %}selected{% endif %}>20 seconds</option>
<option value="30" {% if current_refresh_seconds == 30 %}selected{% endif %}>30 seconds</option>
</select>
<button type="submit">Save Refresh Interval</button>
</form>
</div>
<!-- MP3 metadata defaults --> <!-- MP3 metadata defaults -->
<div class="section"> <div class="section">
<h2>MP3 Metadata Tags</h2> <h2>MP3 Metadata Tags</h2>
@ -246,6 +262,24 @@
</form> </form>
</div> </div>
<!-- Database backup / restore -->
<div class="section">
<h2>Database Backup / Restore</h2>
<p class="copy-hint">Download a copy of the SQLite database before the event. Upload a previous backup to restore it; the current database will be renamed as a timestamped backup.</p>
<form method="POST" style="margin-bottom:1rem">
<input type="hidden" name="action" value="download_db">
<button type="submit">Download Database Backup</button>
</form>
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="action" value="restore_db">
<label for="db_backup">Restore from backup (.db / .sqlite)</label>
<input type="file" id="db_backup" name="db_backup" accept=".db,.sqlite,.sqlite3">
<button type="submit" onclick="return confirm('This will replace the current database. The old one will be kept as a timestamped backup. Continue?')">Restore Database</button>
</form>
</div>
<!-- System reset spans both columns --> <!-- System reset spans both columns -->
<div class="danger-zone"> <div class="danger-zone">
<h2>⚠️ Reset System</h2> <h2>⚠️ Reset System</h2>