Add admin backup routes (create/status/download/verify/delete/restore)

This commit is contained in:
jessikitty committed 2026-09-24 14:31:28 +10:00
1 parent c9ef418cdd
commit c6ffe7f153
1 file changed
+164
+164
View File
@@ -0,0 +1,164 @@
"""
Admin routes for backup, verify and restore.
Restores are deliberately awkward: they need an explicit typed confirmation
and, for a wipe, a second flag. The CLI (`flask backup restore`) is the
better path on a fresh Docker instance where no admin user exists yet.
Created by: Jess Rogerson (yelling commands at Claude.AI)
"""
import os
from flask import (Blueprint, render_template, redirect, url_for, flash,
request, jsonify, send_file, current_app, abort)
from flask_login import login_required, current_user
from app.routes.admin import admin_required
from app.services import backup_service as bsvc
backup_bp = Blueprint('backup', __name__, url_prefix='/admin/backup')
@backup_bp.route('/')
@login_required
@admin_required
def index():
"""Backup management page."""
backups = bsvc.list_backups()
total_bytes = sum(b['size_bytes'] for b in backups)
uploads_root = bsvc.upload_folder()
upload_files = list(bsvc.walk_uploads(uploads_root))
uploads_bytes = sum(os.path.getsize(p) for p, _ in upload_files)
expected = bsvc.referenced_files()
on_disk = {rel for _, rel in upload_files}
missing = sorted(expected - on_disk)
orphaned = sorted(on_disk - expected)
return render_template(
'admin/backup.html',
backups=backups,
backup_folder=bsvc.backup_folder(),
backup_total_human=bsvc.human_size(total_bytes),
uploads_root=uploads_root,
upload_file_count=len(upload_files),
uploads_human=bsvc.human_size(uploads_bytes),
missing=missing,
orphaned=orphaned,
dialect=bsvc.database_dialect(),
sqlite_path=bsvc.sqlite_db_path(),
keep=current_app.config.get('BACKUP_KEEP'),
)
@backup_bp.route('/create', methods=['POST'])
@login_required
@admin_required
def create():
"""Start a backup in the background and hand back a job id."""
label = (request.form.get('label') or '').strip() or None
job_id = bsvc.start_backup_job(
current_app._get_current_object(),
created_by=current_user.username,
label=label,
keep=current_app.config.get('BACKUP_KEEP'),
)
return jsonify({'job_id': job_id})
@backup_bp.route('/status/<job_id>')
@login_required
@admin_required
def status(job_id):
job = bsvc.get_job(job_id)
if not job:
return jsonify({'error': 'Unknown job'}), 404
return jsonify(job)
@backup_bp.route('/download/<path:filename>')
@login_required
@admin_required
def download(filename):
folder = bsvc.backup_folder()
safe = os.path.basename(filename)
path = os.path.join(folder, safe)
if not os.path.isfile(path):
abort(404)
return send_file(path, as_attachment=True, download_name=safe)
@backup_bp.route('/verify/<path:filename>')
@login_required
@admin_required
def verify(filename):
folder = bsvc.backup_folder()
safe = os.path.basename(filename)
path = os.path.join(folder, safe)
if not os.path.isfile(path):
return jsonify({'error': 'Not found'}), 404
report = bsvc.verify_backup(path)
return jsonify({
'ok': report['ok'],
'checked': report['checked'],
'failed': report['failed'][:20],
'missing': report['missing'][:20],
'errors': report['errors'],
})
@backup_bp.route('/delete/<path:filename>', methods=['POST'])
@login_required
@admin_required
def delete(filename):
try:
removed = bsvc.delete_backup(filename)
flash(f'Deleted backup {removed}', 'success')
except FileNotFoundError:
flash('That backup no longer exists.', 'warning')
except ValueError as exc:
flash(str(exc), 'danger')
return redirect(url_for('backup.index'))
@backup_bp.route('/restore/<path:filename>', methods=['POST'])
@login_required
@admin_required
def restore(filename):
"""Restore an archive that already sits in the backup folder."""
folder = bsvc.backup_folder()
safe = os.path.basename(filename)
path = os.path.join(folder, safe)
if not os.path.isfile(path):
flash('That backup no longer exists.', 'warning')
return redirect(url_for('backup.index'))
if request.form.get('confirm', '').strip().upper() != 'RESTORE':
flash('Restore cancelled: confirmation text did not match.', 'warning')
return redirect(url_for('backup.index'))
mode = request.form.get('mode', 'merge')
force = request.form.get('force') == 'on'
overwrite = request.form.get('overwrite_files') == 'on'
try:
summary = bsvc.restore_backup(path, mode=mode, force=force,
overwrite_files=overwrite)
except Exception as exc: # noqa: BLE001 - reported to the admin
current_app.logger.exception('Restore failed')
flash(f'Restore failed: {exc}', 'danger')
return redirect(url_for('backup.index'))
inserted = ', '.join(f"{v} {k}" for k, v in summary['inserted'].items() if v)
flash(
f"Restore complete ({mode} mode). Rows added: {inserted or 'none'}. "
f"Files written: {summary['files_written']} "
f"({summary['files_bytes_human']}), skipped: {summary['files_skipped']}.",
'success'
)
for warning in summary['warnings']:
flash(warning, 'info')
return redirect(url_for('backup.index'))