Add dashboard auto-refresh, status filters, rate limiting, database backup/restore
This commit is contained in:
parent
f32efc42c5
commit
5ebce2baff
5 changed files with 138 additions and 29 deletions
67
app.py
67
app.py
|
|
@ -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
3
booth_settings.json
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"refresh_seconds": 20
|
||||||
|
}
|
||||||
|
|
@ -13,3 +13,4 @@ gunicorn
|
||||||
python-dotenv
|
python-dotenv
|
||||||
werkzeug
|
werkzeug
|
||||||
mutagen
|
mutagen
|
||||||
|
flask-limiter
|
||||||
|
|
|
||||||
|
|
@ -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 -->
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -256,7 +290,7 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
Reference in a new issue