Add revision history log and music files ZIP backup

This commit is contained in:
Troll (Hermes Agent) 2026-08-04 16:54:51 +00:00
parent 8246f641a2
commit cda4d57859
4 changed files with 127 additions and 14 deletions

50
app.py
View file

@ -58,7 +58,7 @@ 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, get_db
from models import SCHEMA, get_requests_by_email
from models import SCHEMA, get_requests_by_email, log_revision, list_revision_history
# Audio metadata import
from mutagen.mp3 import MP3
@ -630,6 +630,8 @@ def revise(token):
# Increment revision counter and archive current files before new versions are uploaded.
new_count = current_count + 1
upload_dir = Path(current_app.config['UPLOAD_FOLDER']) / str(req['id'])
old_a, old_b = req.get('song_a_path'), req.get('song_b_path')
new_a, new_b = old_a, old_b
if upload_dir.exists():
for field, version in (('song_a_path', 'A'), ('song_b_path', 'B')):
path = req.get(field)
@ -638,9 +640,14 @@ def revise(token):
archived = upload_dir / f"Rev{new_count}-{old.name}"
try:
old.rename(archived)
if field == 'song_a_path':
new_a = str(archived)
else:
new_b = str(archived)
req[field] = str(archived)
except OSError:
pass
log_revision(req['id'], new_count, note, old_a=old_a, old_b=old_b, new_a=new_a, new_b=new_b)
update_request(req['id'], revision_note=note, status='revisions_requested',
song_a_path=req.get('song_a_path'), song_b_path=req.get('song_b_path'),
customer_approved='none', revision_count=new_count)
@ -963,7 +970,7 @@ def admin_request(rid):
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, extra_files=extra_files, callback_url=build_prompt_callback_url(rid))
return render_template('admin/request.html', req=req, statuses=STATUS_LABELS, file_exists=file_exists, basename=basename, extra_files=extra_files, callback_url=build_prompt_callback_url(rid), revision_history=list_revision_history(rid))
@app.route('/admin/request/<int:rid>/delete', methods=['POST'])
@ -1088,19 +1095,20 @@ def admin_settings():
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()
# Attempt to create missing tables and add missing columns via ALTER TABLE.
try:
db = get_db()
db.executescript(SCHEMA)
if not health['ok'] and health['missing_columns']:
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')
flash(f'Created missing tables and added columns: {", ".join(health["missing_columns"])}. Please refresh the page.', 'success')
else:
flash('Database schema is up to date.', 'success')
db.commit()
except Exception as e:
flash(f'Failed to fix database: {e}', 'error')
return redirect(url_for('admin_settings'))
elif action == 'reset_system':
@ -1199,6 +1207,24 @@ def admin_settings():
flash('Database file not found.', 'error')
return redirect(url_for('admin_settings'))
elif action == 'download_uploads_zip':
# Zip all files under UPLOAD_FOLDER and send as a download.
import zipfile
zip_path = db_path.with_suffix('.uploads-' + str(int(time.time())) + '.zip')
try:
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
if upload_root.exists():
for entry in upload_root.rglob('*'):
if entry.is_file():
zf.write(str(entry), str(entry.relative_to(upload_root)))
return send_file(str(zip_path), as_attachment=True, download_name='theme-song-booth-uploads.zip')
except Exception as e:
flash(f'Failed to create uploads ZIP: {e}', 'error')
return redirect(url_for('admin_settings'))
finally:
if zip_path.exists():
zip_path.unlink()
elif action == 'restore_db':
# Replace the current database file with an uploaded SQLite backup.
file_obj = request.files.get('db_backup')

View file

@ -55,6 +55,21 @@ CREATE TABLE IF NOT EXISTS requests (
CREATE INDEX IF NOT EXISTS idx_requests_status ON requests(status);
CREATE INDEX IF NOT EXISTS idx_requests_token ON requests(player_token);
CREATE TABLE IF NOT EXISTS revision_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
revision_count INTEGER NOT NULL,
note TEXT,
old_song_a_path TEXT,
old_song_b_path TEXT,
new_song_a_path TEXT,
new_song_b_path TEXT,
FOREIGN KEY (request_id) REFERENCES requests(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_revision_history_request ON revision_history(request_id);
"""
@ -168,6 +183,28 @@ def delete_request(request_id):
db.commit()
def log_revision(request_id, revision_count, note, old_a=None, old_b=None, new_a=None, new_b=None):
"""Record a revision event in the revision_history table."""
db = get_db()
db.execute(
"""INSERT INTO revision_history
(request_id, revision_count, note, old_song_a_path, old_song_b_path, new_song_a_path, new_song_b_path)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(request_id, revision_count, note, old_a, old_b, new_a, new_b)
)
db.commit()
def list_revision_history(request_id):
"""Return all revision history rows for a request, oldest first."""
db = get_db()
rows = db.execute(
'SELECT * FROM revision_history WHERE request_id = ? ORDER BY created_at ASC',
(request_id,)
).fetchall()
return [dict(r) for r in rows]
def reset_all_requests():
"""Delete every row in the requests table and reset id auto-increment."""
db = get_db()

View file

@ -110,6 +110,23 @@
margin-left:1.8rem;
}
.approved-box{font-size:1.2rem;font-weight:bold;color:#fbbf24;}
.history-list{
margin:0;
padding:0;
list-style:none;
}
.history-list li{
border-bottom:1px solid #374151;
padding:.6rem 0;
}
.history-list li:last-child{border-bottom:none;}
.history-list .meta{
font-size:.8rem;
color:#9ca3af;
}
.history-list .note{
margin:.3rem 0 0 0;
}
/* Two-column layout */
.two-col{
@ -291,7 +308,7 @@
{% endif %}
</div>
<!-- Payment & Delivery -->
<!-- Payment &amp; Delivery -->
<div class="section">
<h2>4. Payment &amp; Delivery</h2>
<p>
@ -344,13 +361,36 @@
<input type="text" id="square_payment_ref" name="square_payment_ref" placeholder="e.g. sq0idp-... or receipt number"
{% if req.customer_approved == 'none' %}disabled{% endif %}>
<div class="actions">
<button type="submit" class="success" {% if req.customer_approved == 'none' %}disabled{% endif %}>Mark Paid & Deliver</button>
<button type="submit" class="success" {% if req.customer_approved == 'none' %}disabled{% endif %}>Mark Paid &amp; Deliver</button>
</div>
{% if req.customer_approved == 'none' %}
<p style="margin-top:.5rem;color:#f87171;font-weight:600">⚠️ Customer must approve a version before you can mark paid or deliver.</p>
{% endif %}
</form>
</div>
<!-- Revision History -->
<div class="section">
<h2>Revision History</h2>
{% if revision_history %}
<ul class="history-list">
{% for h in revision_history %}
<li>
<div class="meta">Revision #{{ h.revision_count }} — {{ h.created_at }}
{% if h.old_song_a_path or h.old_song_b_path %}
<br>Archived:
{% if h.old_song_a_path %}<br>A: {{ basename(h.old_song_a_path) }}{% endif %}
{% if h.old_song_b_path %}<br>B: {{ basename(h.old_song_b_path) }}{% endif %}
{% endif %}
</div>
<p class="note">{{ h.note or 'No note provided.' }}</p>
</li>
{% endfor %}
</ul>
{% else %}
<p class="copy-hint">No revisions recorded yet.</p>
{% endif %}
</div>
</div>
</div>

View file

@ -364,6 +364,16 @@
</form>
</div>
<!-- Uploads backup -->
<div class="section">
<h2>Music Files Backup</h2>
<p class="copy-hint">Download all uploaded MP3 and archived revision files as a single ZIP.</p>
<form method="POST">
<input type="hidden" name="action" value="download_uploads_zip">
<button type="submit">Download Music Files ZIP</button>
</form>
</div>
<!-- System reset spans both columns -->
<div class="danger-zone">
<h2>⚠️ Reset System</h2>