38686-vm/core/management/commands/backup_data.py
Konrad du Plessis 0ace7c6786 Phase 1: security fixes + backup/restore tooling + vat_type migrations
Minimal infrastructure push before the bigger feature release (worker/team/
project management UIs, WeasyPrint migration, new models). Deploying this
first gives us a browser-accessible `/backup-data/` endpoint so we can
snapshot production before the bigger change lands.

SECURITY
  - Remove hardcoded Gmail App Password from settings.py (was leaking via
    git history; new password now lives in Flatlogic's `../.env` file)
  - Remove hardcoded SECRET_KEY default; raise ImproperlyConfigured in
    prod if env var missing; dev fallback only when USE_SQLITE is set
  - Flip DEBUG default from 'true' to 'false' so missing env var doesn't
    silently expose tracebacks
  - Remove hardcoded EMAIL_HOST_USER / DEFAULT_FROM_EMAIL defaults
  - Add startup warning when email vars missing in production
  - Fix CSRF_TRUSTED_ORIGINS double-scheme bug (would break with
    pre-prefixed HOST_FQDN env var)

BACKUP / RESTORE
  - New `backup_data` management command — serialises every core + auth
    row to a timestamped JSON file. Gracefully handles models missing at
    older schema versions (WorkerCertificate/Warning imported optionally).
  - New `restore_data` management command — loads JSON back into the DB
    with a populated-DB safety guard and transactional all-or-nothing
    semantics.
  - New `/backup-data/` admin-only URL — downloads the JSON to browser.
  - New `/restore-data/` admin-only URL — upload form with CSRF and
    explicit confirm checkbox before any data is loaded.

MIGRATIONS
  - Add 0007_vat_type_default + 0008_vat_type_default_none (change
    ExpenseReceipt.vat_type default to 'None').
  - Update models.py to match migration 0008's end state.

HOUSEKEEPING
  - Extend .gitignore: .claude/, .vscode/, .idea/, test_*.pdf,
    test_*.json, nul, backups/.

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

142 lines
4.9 KiB
Python

# === BACKUP DATA MANAGEMENT COMMAND ===
# Exports every row of every core model to a single JSON file that can
# be restored later via `python manage.py restore_data <file.json>`.
#
# WHY THIS EXISTS:
# Flatlogic doesn't expose MySQL directly — no mysqldump, no SSH, no
# DB console. Django's built-in `dumpdata` / `loaddata` give us a
# platform-independent backup format that travels with the code.
#
# WHY NOT JUST USE `dumpdata`?
# This command is a thin wrapper around dumpdata that:
# - Pins the exact set of app+model rows we want to back up
# - Writes to a timestamped file so you never overwrite a backup
# - Includes Users + Groups + auth content types (so permissions
# restore correctly too)
# - Prints a row-count summary so you can confirm it worked
#
# USAGE (local):
# python manage.py backup_data → backups/foxlog_YYYYMMDD_HHMMSS.json
# python manage.py backup_data --output=my.json → my.json
#
# USAGE (Flatlogic, via browser):
# Visit /backup-data/ as admin — downloads the backup file to your browser.
import datetime
import io
import json
from pathlib import Path
from django.core import serializers
from django.core.management.base import BaseCommand
from django.contrib.auth.models import User, Group, Permission
from django.contrib.contenttypes.models import ContentType
from core.models import (
UserProfile, Project, Worker, Team, WorkLog,
PayrollRecord, Loan, PayrollAdjustment,
ExpenseReceipt, ExpenseLineItem,
)
# WorkerCertificate and WorkerWarning were added in a later migration.
# Import them optionally so this backup command works during a multi-phase
# deploy where the backup tool ships before those models do.
try:
from core.models import WorkerCertificate, WorkerWarning
_HAS_WORKER_CERTS_WARNINGS = True
except ImportError:
_HAS_WORKER_CERTS_WARNINGS = False
# === BACKUP SCOPE ===
# The exact list of models we back up. Order matters for restore —
# we list models in dependency order (no FK should point at something
# that comes later in the list). Django's loaddata handles this
# correctly regardless, but keeping it sorted helps humans read it.
MODELS_TO_BACKUP = [
# Auth fundamentals — restore these first so FKs from UserProfile
# etc. find their user rows.
ContentType,
Permission,
Group,
User,
# Core app
UserProfile,
Project,
Worker,
Team,
WorkLog,
PayrollRecord,
Loan,
PayrollAdjustment,
ExpenseReceipt,
ExpenseLineItem,
]
# Append the cert/warning models only if they're available in this deploy
if _HAS_WORKER_CERTS_WARNINGS:
MODELS_TO_BACKUP.extend([WorkerCertificate, WorkerWarning])
def build_backup_payload():
"""Return (json_str, summary_dict) for the current DB state.
Separated from the Command class so the browser view can reuse it
to stream the backup to the user's browser.
"""
# Pull every row of every model we care about, serialise as JSON.
# serializers.serialize("json", queryset) returns a JSON string.
# We concatenate by building one big list first, then dumping once.
all_rows = []
summary = {}
for model in MODELS_TO_BACKUP:
qs = list(model.objects.all())
summary[f"{model._meta.app_label}.{model._meta.model_name}"] = len(qs)
# Use the built-in Django serializer for proper natural-key support
serialized = serializers.serialize("python", qs)
all_rows.extend(serialized)
payload = {
"version": 1,
"exported_at": datetime.datetime.now().isoformat(),
"row_counts": summary,
"data": all_rows,
}
return json.dumps(payload, indent=2, default=str), summary
class Command(BaseCommand):
help = (
"Export every core-app row to a JSON file for backup/restore. "
"Writes to backups/foxlog_<timestamp>.json unless --output is given."
)
def add_arguments(self, parser):
parser.add_argument(
"--output",
type=str,
default=None,
help="Output filepath. Default: backups/foxlog_<timestamp>.json",
)
def handle(self, *args, **options):
json_str, summary = build_backup_payload()
# Default path: ./backups/foxlog_<timestamp>.json
if options["output"]:
output_path = Path(options["output"])
else:
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
output_path = Path("backups") / f"foxlog_{ts}.json"
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json_str, encoding="utf-8")
# Print a summary so you can verify at a glance
self.stdout.write(self.style.SUCCESS(
f"Backup written to: {output_path}"
))
self.stdout.write(f"File size: {output_path.stat().st_size:,} bytes")
self.stdout.write("Row counts by model:")
for model_name, count in sorted(summary.items()):
self.stdout.write(f" {model_name:<40} {count:>6}")