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>
142 lines
5.1 KiB
Python
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}")
|