Add admin Settings page with DB health check, stats, and system reset
This commit is contained in:
parent
2b960bd3d5
commit
51d1b4a4d9
3 changed files with 333 additions and 5 deletions
117
app.py
117
app.py
|
|
@ -19,6 +19,7 @@ Admin routes:
|
||||||
- /admin/login -> password login
|
- /admin/login -> password login
|
||||||
- /admin/logout -> clears session
|
- /admin/logout -> clears session
|
||||||
- /admin -> dashboard queue
|
- /admin -> dashboard queue
|
||||||
|
- /admin/settings -> health check, DB stats, disk usage, system reset
|
||||||
- /admin/request/<id> -> detail/edit page for a single request
|
- /admin/request/<id> -> detail/edit page for a single request
|
||||||
- /admin/request/<id>/delete -> deletes one request and its files
|
- /admin/request/<id>/delete -> deletes one request and its files
|
||||||
- /admin/reset -> deletes ALL requests and ALL files
|
- /admin/reset -> deletes ALL requests and ALL files
|
||||||
|
|
@ -38,7 +39,8 @@ from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
# Project imports
|
# Project imports
|
||||||
from config import Config
|
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
|
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
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# App setup
|
# App setup
|
||||||
|
|
@ -425,6 +427,119 @@ def admin_delete_request(rid):
|
||||||
return redirect(url_for('admin_dashboard'))
|
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
|
||||||
|
|
||||||
|
# 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', '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'
|
||||||
|
}
|
||||||
|
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'))
|
||||||
|
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/admin/reset', methods=['POST'])
|
@app.route('/admin/reset', methods=['POST'])
|
||||||
def admin_reset_system():
|
def admin_reset_system():
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,13 @@
|
||||||
font-size:.9rem;
|
font-size:.9rem;
|
||||||
}
|
}
|
||||||
.topbar button.reset-sm:hover{background:#b91c1c;}
|
.topbar button.reset-sm:hover{background:#b91c1c;}
|
||||||
|
.topbar a{
|
||||||
|
color:#93c5fd;
|
||||||
|
text-decoration:none;
|
||||||
|
}
|
||||||
|
.topbar a.settings{
|
||||||
|
color:#10b981;
|
||||||
|
}
|
||||||
|
|
||||||
/* Hidden legacy reset box (kept CSS class for compatibility, not displayed) */
|
/* Hidden legacy reset box (kept CSS class for compatibility, not displayed) */
|
||||||
.reset-box{display:none;}
|
.reset-box{display:none;}
|
||||||
|
|
@ -100,11 +107,9 @@
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<!-- Topbar: Reset System button and Log out link -->
|
<!-- Topbar: Settings link and Log out link -->
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<form method="POST" action="{{ url_for('admin_reset_system') }}" onsubmit="return confirm('ARE YOU SURE? This will delete ALL requests and ALL uploaded files. This cannot be undone.')">
|
<a href="{{ url_for('admin_settings') }}" class="settings">Settings</a>
|
||||||
<button type="submit" class="reset-sm">Reset System</button>
|
|
||||||
</form>
|
|
||||||
<a href="{{ url_for('admin_logout') }}" class="logout">Log out</a>
|
<a href="{{ url_for('admin_logout') }}" class="logout">Log out</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
208
templates/admin/settings.html
Normal file
208
templates/admin/settings.html
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Admin Settings</title>
|
||||||
|
<style>
|
||||||
|
/*
|
||||||
|
Admin settings / maintenance page.
|
||||||
|
Shows database health, statistics, upload disk usage,
|
||||||
|
and houses the dangerous system reset button.
|
||||||
|
*/
|
||||||
|
body{
|
||||||
|
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
||||||
|
background:#111827;
|
||||||
|
color:#f3f4f6;
|
||||||
|
margin:0;
|
||||||
|
padding:1rem;
|
||||||
|
line-height:1.5;
|
||||||
|
}
|
||||||
|
.container{max-width:900px;margin:0 auto;}
|
||||||
|
h1,h2{color:#60a5fa;}
|
||||||
|
a{color:#93c5fd;text-decoration:none;}
|
||||||
|
.topbar{
|
||||||
|
float:right;
|
||||||
|
display:flex;
|
||||||
|
gap:.75rem;
|
||||||
|
align-items:center;
|
||||||
|
}
|
||||||
|
.topbar a{
|
||||||
|
color:#f87171;
|
||||||
|
}
|
||||||
|
.section{
|
||||||
|
background:#1f2937;
|
||||||
|
padding:1rem;
|
||||||
|
border-radius:.5rem;
|
||||||
|
margin-bottom:1rem;
|
||||||
|
}
|
||||||
|
.flash{
|
||||||
|
padding:.8rem;
|
||||||
|
background:#064e3b;
|
||||||
|
border-radius:.5rem;
|
||||||
|
margin-bottom:1rem;
|
||||||
|
}
|
||||||
|
.flash.error{background:#450a0a;}
|
||||||
|
.stat-grid{
|
||||||
|
display:grid;
|
||||||
|
grid-template-columns:repeat(auto-fit,minmax(220px,1fr));
|
||||||
|
gap:1rem;
|
||||||
|
}
|
||||||
|
.stat-card{
|
||||||
|
background:#111827;
|
||||||
|
padding:.8rem;
|
||||||
|
border-radius:.5rem;
|
||||||
|
}
|
||||||
|
.stat-card .value{
|
||||||
|
font-size:1.5rem;
|
||||||
|
font-weight:700;
|
||||||
|
color:#60a5fa;
|
||||||
|
}
|
||||||
|
.stat-card .label{
|
||||||
|
font-size:.85rem;
|
||||||
|
color:#9ca3af;
|
||||||
|
}
|
||||||
|
.health-ok{
|
||||||
|
color:#10b981;
|
||||||
|
font-weight:700;
|
||||||
|
}
|
||||||
|
.health-bad{
|
||||||
|
color:#f87171;
|
||||||
|
font-weight:700;
|
||||||
|
}
|
||||||
|
.status-list{
|
||||||
|
margin:0;
|
||||||
|
padding-left:1.2rem;
|
||||||
|
}
|
||||||
|
.status-list li{
|
||||||
|
margin-bottom:.3rem;
|
||||||
|
}
|
||||||
|
button{
|
||||||
|
padding:.7rem 1rem;
|
||||||
|
border:none;
|
||||||
|
border-radius:.5rem;
|
||||||
|
background:#3b82f6;
|
||||||
|
color:#fff;
|
||||||
|
font-weight:700;
|
||||||
|
cursor:pointer;
|
||||||
|
margin-right:.5rem;
|
||||||
|
}
|
||||||
|
button.fix{
|
||||||
|
background:#10b981;
|
||||||
|
}
|
||||||
|
button.danger{
|
||||||
|
background:#dc2626;
|
||||||
|
}
|
||||||
|
.danger-zone{
|
||||||
|
background:#450a0a;
|
||||||
|
border:1px solid #7f1d1d;
|
||||||
|
border-radius:.5rem;
|
||||||
|
padding:1rem;
|
||||||
|
}
|
||||||
|
.danger-zone h2{
|
||||||
|
color:#f87171;
|
||||||
|
margin-top:0;
|
||||||
|
}
|
||||||
|
.path{
|
||||||
|
font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
|
||||||
|
font-size:.85rem;
|
||||||
|
word-break:break-all;
|
||||||
|
color:#9ca3af;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="topbar">
|
||||||
|
<a href="{{ url_for('admin_dashboard') }}">Dashboard →</a>
|
||||||
|
<a href="{{ url_for('admin_logout') }}">Log out</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>Admin Settings</h1>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="flash {{ category }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<!-- Database health -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Database Health</h2>
|
||||||
|
{% if health.ok %}
|
||||||
|
<p class="health-ok">✅ {{ health.message }}</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="health-bad">❌ {{ health.message }}</p>
|
||||||
|
{% if health.missing_columns %}
|
||||||
|
<p>Missing columns:</p>
|
||||||
|
<ul class="status-list">
|
||||||
|
{% for col in health.missing_columns %}
|
||||||
|
<li>{{ col }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<form method="POST" style="margin-top:.8rem">
|
||||||
|
<input type="hidden" name="action" value="fix_db">
|
||||||
|
<button type="submit" class="fix">Fix Missing Columns</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
<p class="path">DB path: {{ db_path }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Statistics -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Database Statistics</h2>
|
||||||
|
<div class="stat-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="value">{{ total_records }}</div>
|
||||||
|
<div class="label">Total requests</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="value">{{ db_size }}</div>
|
||||||
|
<div class="label">Database size</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="value">{{ upload_file_count }}</div>
|
||||||
|
<div class="label">Uploaded MP3 files</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="value">{{ upload_dir_count }}</div>
|
||||||
|
<div class="label">Request upload folders</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="value">{{ upload_size }}</div>
|
||||||
|
<div class="label">Total upload size</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if status_counts %}
|
||||||
|
<h3 style="margin-top:1.2rem;color:#93c5fd">By Status</h3>
|
||||||
|
<ul class="status-list">
|
||||||
|
{% for key,count in status_counts.items() %}
|
||||||
|
<li>{{ statuses[key] }} ({{ key }}): <strong>{{ count }}</strong></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p style="margin-top:1rem;color:#9ca3af">No requests yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Paths -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Paths</h2>
|
||||||
|
<p><strong>Database:</strong><br><span class="path">{{ db_path }}</span></p>
|
||||||
|
<p><strong>Uploads:</strong><br><span class="path">{{ upload_path }}</span></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- System reset -->
|
||||||
|
<div class="danger-zone">
|
||||||
|
<h2>⚠️ Reset System</h2>
|
||||||
|
<p>Use this only at the start of a new event. It will delete every request and every uploaded MP3 file. This cannot be undone.</p>
|
||||||
|
<form method="POST" action="{{ url_for('admin_settings') }}" onsubmit="return confirm('ARE YOU SURE? This will delete ALL requests and ALL uploaded files. This cannot be undone.')">
|
||||||
|
<input type="hidden" name="action" value="reset_system">
|
||||||
|
<button type="submit" class="danger">Reset System</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in a new issue