Konrad du Plessis 3c28387dd3 WIP: 2026-04-22 session checkpoint
Complete working state of the session. Will be split into two deploy
phases (safety scaffolding then feature release) before merging to ai-dev.

Includes:
- Security fixes (email creds / SECRET_KEY / DEBUG / CSRF)
- Backup + restore management commands and browser endpoints
- WeasyPrint migration (replaces xhtml2pdf)
- New Worker fields + WorkerCertificate + WorkerWarning models
- Worker / Team / Project friendly management UIs
- Dashboard cert-expiry card + Manage All buttons
- Bootstrap tooltips (global init + theme-aware CSS)
- Django admin template override (taller M2M pickers)
- Money filter for ZAR currency formatting
- Resources dropdown nav
- Massive CLAUDE.md expansion + deploy plan docs

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:19:15 +02:00

142 lines
5.1 KiB
Python

# === RESTORE DATA MANAGEMENT COMMAND ===
# Restores a backup produced by `backup_data` — takes a JSON file and
# loads every row into the database.
#
# SAFETY:
# By default this command REFUSES to run against a non-empty database
# (prevents accidentally overwriting live data). Pass --force to
# bypass — but only when you know the target is empty or already
# matches the backup.
#
# USAGE (local):
# python manage.py restore_data backups/foxlog_20260421_120000.json
# python manage.py restore_data backup.json --force (overwrite existing)
#
# USAGE (Flatlogic, via browser):
# Upload a .json backup file via /restore-data/ (admin only).
#
# BEHAVIOUR:
# Uses Django's built-in `loaddata` under the hood, which:
# - Updates existing rows if their pk matches (no duplicates)
# - Creates new rows for any pk not yet in the DB
# - Respects FK/M2M dependencies
# - Runs inside a transaction — if any row fails, nothing is saved
import json
import sys
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from django.core.management import call_command
from django.db import transaction
from django.contrib.auth.models import User
from core.models import Worker, WorkLog, PayrollRecord
def check_database_is_populated():
"""Return True if the database already has meaningful data.
Used as a guardrail: by default we refuse to restore into a DB that
already contains workers, work logs, or payroll records, because
that could double-insert and corrupt the state.
"""
has_workers = Worker.objects.exists()
has_logs = WorkLog.objects.exists()
has_payments = PayrollRecord.objects.exists()
return has_workers or has_logs or has_payments
def restore_from_json_string(json_str):
"""Load a JSON backup string into the database.
Returns (success, message_or_summary). Used both by this management
command and by the browser-accessible `/restore-data/` view so the
same logic runs in both places.
Raises no exceptions — returns (False, error_message) on failure so
the caller (CLI or web view) can format the error appropriately.
"""
try:
payload = json.loads(json_str)
except json.JSONDecodeError as e:
return False, f"File is not valid JSON: {e}"
# Backups produced by `backup_data` wrap rows in a top-level dict.
# Raw dumpdata output is a bare list — support both for flexibility.
if isinstance(payload, dict) and "data" in payload:
rows = payload["data"]
elif isinstance(payload, list):
rows = payload
else:
return False, "Unexpected JSON structure — expected dict with 'data' key or a list."
if not rows:
return False, "Backup file contains no rows."
# Write the rows to a tmp file then let Django's loaddata do the work
# (it handles FK order, transaction wrapping, and natural keys).
import tempfile
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False,
encoding="utf-8") as tmp:
# loaddata expects the bare list format
json.dump(rows, tmp, default=str)
tmp_path = tmp.name
try:
with transaction.atomic():
call_command("loaddata", tmp_path, verbosity=0)
except Exception as e:
return False, f"Restore failed: {e}"
finally:
try:
Path(tmp_path).unlink()
except Exception:
pass # cleanup best-effort
# Build a summary for the caller to display
summary = {
"users": User.objects.count(),
"workers": Worker.objects.count(),
"work_logs": WorkLog.objects.count(),
"payroll_records": PayrollRecord.objects.count(),
"rows_in_backup": len(rows),
}
return True, summary
class Command(BaseCommand):
help = "Restore a JSON backup produced by `backup_data`."
def add_arguments(self, parser):
parser.add_argument("backup_file", type=str, help="Path to a .json backup file")
parser.add_argument(
"--force",
action="store_true",
help="Allow restore even if the target database already has data",
)
def handle(self, *args, **options):
backup_path = Path(options["backup_file"])
if not backup_path.exists():
raise CommandError(f"Backup file not found: {backup_path}")
if not options["force"] and check_database_is_populated():
raise CommandError(
"Database already contains data (workers/logs/payments). "
"Restoring now could duplicate or corrupt rows.\n"
"If you really want to proceed, run again with --force.\n"
"Or flush first: python manage.py flush (irreversible)."
)
json_str = backup_path.read_text(encoding="utf-8")
ok, result = restore_from_json_string(json_str)
if not ok:
raise CommandError(result)
self.stdout.write(self.style.SUCCESS("Restore complete."))
self.stdout.write("Rows in database after restore:")
for k, v in result.items():
self.stdout.write(f" {k}: {v}")