From 0ace7c6786a8963cdbc14110d337c894cf3ba421 Mon Sep 17 00:00:00 2001 From: Konrad du Plessis Date: Wed, 22 Apr 2026 00:28:21 +0200 Subject: [PATCH] Phase 1: security fixes + backup/restore tooling + vat_type migrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 17 ++- config/settings.py | 90 +++++++++-- core/management/commands/backup_data.py | 141 ++++++++++++++++++ core/management/commands/restore_data.py | 141 ++++++++++++++++++ core/migrations/0007_vat_type_default.py | 18 +++ core/migrations/0008_vat_type_default_none.py | 18 +++ core/models.py | 2 +- core/urls.py | 7 + core/views.py | 117 +++++++++++++++ 9 files changed, 535 insertions(+), 16 deletions(-) create mode 100644 core/management/commands/backup_data.py create mode 100644 core/management/commands/restore_data.py create mode 100644 core/migrations/0007_vat_type_default.py create mode 100644 core/migrations/0008_vat_type_default_none.py diff --git a/.gitignore b/.gitignore index be97e45..dab9540 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,23 @@ __pycache__/ *.pyc *.pyo .env +.env.* *.db *.sqlite3 +*.sqlite3-journal .DS_Store media/ -.venv/ \ No newline at end of file +.venv/ + +# Claude Code / IDE +.claude/ +.vscode/ +.idea/ + +# Dev artifacts — test PDFs, backup files, accidental shell artifacts +test_*.pdf +test_*.json +nul + +# Local backup downloads — these should never be in git +backups/ diff --git a/config/settings.py b/config/settings.py index 865a596..c9508d1 100644 --- a/config/settings.py +++ b/config/settings.py @@ -12,13 +12,40 @@ https://docs.djangoproject.com/en/5.2/ref/settings/ from pathlib import Path import os +from django.core.exceptions import ImproperlyConfigured from dotenv import load_dotenv BASE_DIR = Path(__file__).resolve().parent.parent load_dotenv(BASE_DIR.parent / ".env") -SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "change-me") -DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true" +# === DEBUG === +# DEBUG defaults to FALSE — must be explicitly enabled via env var. +# Previously defaulted to "true" which exposed full tracebacks and +# settings to anyone who hit a 500 error in production. +DEBUG = os.getenv("DJANGO_DEBUG", "false").lower() == "true" + +# === DEV MODE DETECTION === +# Local dev uses SQLite (see run_dev.bat). When USE_SQLITE is set we're +# in dev and can relax a few "must be set in prod" checks. +_IS_DEV = os.getenv("USE_SQLITE", "").lower() == "true" + +# === SECRET_KEY === +# Must be provided via DJANGO_SECRET_KEY env var in any non-dev deploy. +# In dev mode (USE_SQLITE=true) we fall back to a known-insecure key so +# local development works out of the box. In prod the absence of the +# env var raises a startup error rather than silently using a weak key. +SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "") +if not SECRET_KEY: + if _IS_DEV or DEBUG: + # Dev-only key — NEVER set this value in a production env var. + SECRET_KEY = "dev-only-insecure-key-do-not-use-in-production" + else: + raise ImproperlyConfigured( + "DJANGO_SECRET_KEY environment variable is not set. " + "Set it in the deploy platform's environment variables (or .env file). " + "Use `python -c \"import secrets; print(secrets.token_urlsafe(64))\"` " + "to generate a new one." + ) ALLOWED_HOSTS = [ "127.0.0.1", @@ -27,17 +54,29 @@ ALLOWED_HOSTS = [ os.getenv("HOST_FQDN", ""), ] +# === CSRF TRUSTED ORIGINS === +# Build the list, then normalise each entry to have an https:// prefix. +# Guard against the double-prefix bug: if the user sets HOST_FQDN to +# "https://example.com" (with a scheme), the raw f-string would produce +# "https://https://example.com" which Django rejects. CSRF_TRUSTED_ORIGINS = [ origin for origin in [ "foxlog.flatlogic.app", os.getenv("HOST_FQDN", ""), - os.getenv("CSRF_TRUSTED_ORIGIN", "") + os.getenv("CSRF_TRUSTED_ORIGIN", ""), ] if origin ] -CSRF_TRUSTED_ORIGINS = [ - f"https://{host}" if not host.startswith(("http://", "https://")) else host - for host in CSRF_TRUSTED_ORIGINS -] + + +def _normalize_origin(host): + """Ensure `host` has an http:// or https:// scheme; default to https.""" + host = host.strip() + if host.startswith(("http://", "https://")): + return host + return f"https://{host}" + + +CSRF_TRUSTED_ORIGINS = [_normalize_origin(h) for h in CSRF_TRUSTED_ORIGINS] # Cookies must always be HTTPS-only; SameSite=Lax keeps CSRF working behind the proxy. SESSION_COOKIE_SECURE = True @@ -160,29 +199,52 @@ MEDIA_URL = '/media/' MEDIA_ROOT = BASE_DIR / 'media' # === EMAIL CONFIGURATION === -# Uses Gmail SMTP with an App Password to send payslip PDFs and receipts. -# The App Password is a 16-character code from Google Account settings — -# it lets the app send email through Gmail without your actual password. +# NO FALLBACKS for credentials — they MUST come from environment variables +# (the Flatlogic .env file at `../.env`). Previous versions had the Gmail +# App Password committed in source as a fallback default, which is a +# critical security leak via git history. In local dev (USE_SQLITE=true) +# empty credentials are fine; email sends will just fail with an auth +# error — which is what you want locally. EMAIL_BACKEND = os.getenv( "EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend" ) EMAIL_HOST = os.getenv("EMAIL_HOST", "smtp.gmail.com") EMAIL_PORT = int(os.getenv("EMAIL_PORT", "587")) -EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "konrad@foxfitt.co.za") -EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "cwvhpcwyijneukax") +EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "") # set via .env +EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "") # set via .env EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "true").lower() == "true" EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "false").lower() == "true" -DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "konrad+foxlog@foxfitt.co.za") +DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "") CONTACT_EMAIL_TO = [ item.strip() for item in os.getenv("CONTACT_EMAIL_TO", DEFAULT_FROM_EMAIL).split(",") if item.strip() ] -# Spark Receipt Email — payslip and receipt PDFs are sent here for accounting import +# Spark Receipt Email — payslip and receipt PDFs routed here for accounting import. +# This is a routing address, not a secret, so a default is acceptable. SPARK_RECEIPT_EMAIL = os.getenv("SPARK_RECEIPT_EMAIL", "foxfitt-ed9wc+expense@to.sparkreceipt.com") +# Fail loudly in production if critical email vars are missing — catches the +# "I forgot to set env vars on the new deploy platform" mistake before a user +# triggers a payroll payment and the email silently fails. +if not DEBUG and not _IS_DEV: + _missing_email_vars = [ + name for name, val in [ + ("EMAIL_HOST_USER", EMAIL_HOST_USER), + ("EMAIL_HOST_PASSWORD", EMAIL_HOST_PASSWORD), + ("DEFAULT_FROM_EMAIL", DEFAULT_FROM_EMAIL), + ] if not val + ] + if _missing_email_vars: + import logging + logging.getLogger(__name__).warning( + "Email configuration incomplete in production. Missing env vars: %s. " + "Payslip and receipt emails will fail to send until these are set.", + ", ".join(_missing_email_vars), + ) + # When both TLS and SSL flags are enabled, prefer SSL explicitly if EMAIL_USE_SSL: EMAIL_USE_TLS = False diff --git a/core/management/commands/backup_data.py b/core/management/commands/backup_data.py new file mode 100644 index 0000000..bbf8b86 --- /dev/null +++ b/core/management/commands/backup_data.py @@ -0,0 +1,141 @@ +# === 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 `. +# +# 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_.json unless --output is given." + ) + + def add_arguments(self, parser): + parser.add_argument( + "--output", + type=str, + default=None, + help="Output filepath. Default: backups/foxlog_.json", + ) + + def handle(self, *args, **options): + json_str, summary = build_backup_payload() + + # Default path: ./backups/foxlog_.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}") diff --git a/core/management/commands/restore_data.py b/core/management/commands/restore_data.py new file mode 100644 index 0000000..9bda34e --- /dev/null +++ b/core/management/commands/restore_data.py @@ -0,0 +1,141 @@ +# === 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}") diff --git a/core/migrations/0007_vat_type_default.py b/core/migrations/0007_vat_type_default.py new file mode 100644 index 0000000..15345c3 --- /dev/null +++ b/core/migrations/0007_vat_type_default.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2026-04-20 19:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0006_worker_drivers_license_worker_has_drivers_license_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='expensereceipt', + name='vat_type', + field=models.CharField(choices=[('Included', 'Included'), ('Excluded', 'Excluded'), ('None', 'None')], default='Included', max_length=20), + ), + ] diff --git a/core/migrations/0008_vat_type_default_none.py b/core/migrations/0008_vat_type_default_none.py new file mode 100644 index 0000000..a761087 --- /dev/null +++ b/core/migrations/0008_vat_type_default_none.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2026-04-20 19:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0007_vat_type_default'), + ] + + operations = [ + migrations.AlterField( + model_name='expensereceipt', + name='vat_type', + field=models.CharField(choices=[('Included', 'Included'), ('Excluded', 'Excluded'), ('None', 'None')], default='None', max_length=20), + ), + ] diff --git a/core/models.py b/core/models.py index 381ab0e..59eea84 100644 --- a/core/models.py +++ b/core/models.py @@ -195,7 +195,7 @@ class ExpenseReceipt(models.Model): vendor_name = models.CharField(max_length=200) description = models.TextField(blank=True) payment_method = models.CharField(max_length=20, choices=METHOD_CHOICES) - vat_type = models.CharField(max_length=20, choices=VAT_CHOICES) + vat_type = models.CharField(max_length=20, choices=VAT_CHOICES, default='None') subtotal = models.DecimalField(max_digits=12, decimal_places=2) vat_amount = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00')) total_amount = models.DecimalField(max_digits=12, decimal_places=2) diff --git a/core/urls.py b/core/urls.py index 9d4020c..6cf935d 100644 --- a/core/urls.py +++ b/core/urls.py @@ -70,4 +70,11 @@ urlpatterns = [ # === TEMPORARY: Run migrations from browser === # Visit /run-migrate/ to apply pending database migrations on production. path('run-migrate/', views.run_migrate, name='run_migrate'), + + # === BACKUP / RESTORE (admin-only, browser-accessible) === + # Flatlogic has no SSH/shell — admins use these to snapshot and + # restore all app data via the browser. See CLAUDE.md "Backup & + # Restore" section for the full procedure. + path('backup-data/', views.backup_data, name='backup_data'), + path('restore-data/', views.restore_data, name='restore_data'), ] diff --git a/core/views.py b/core/views.py index cc8ff40..e895c75 100644 --- a/core/views.py +++ b/core/views.py @@ -16,6 +16,7 @@ from django.db.models.functions import TruncMonth from django.contrib import messages from django.contrib.auth.decorators import login_required from django.http import JsonResponse, HttpResponseForbidden, HttpResponse +from django.middleware.csrf import get_token from django.core.mail import EmailMultiAlternatives from django.template.loader import render_to_string from django.utils.html import strip_tags @@ -2633,3 +2634,119 @@ def run_migrate(request): '', status=500, ) + + +# === BACKUP / RESTORE (browser-accessible, admin-only) === +# Flatlogic has no shell/SSH — admins need to backup and restore via browser. +# These views wrap the `backup_data` and `restore_data` management commands +# and render a minimal HTML UI. Safe to leave in place in production. + +@login_required +def backup_data(request): + """Download the complete app data as a timestamped JSON file. + + Admin-only. Serves the backup as a browser download so it lands + safely on the admin's laptop rather than the server filesystem + (which is ephemeral on Flatlogic). + """ + if not is_admin(request.user): + return HttpResponseForbidden("Admin access required.") + + from core.management.commands.backup_data import build_backup_payload + import datetime as _dt + + json_str, summary = build_backup_payload() + filename = f'foxlog_backup_{_dt.datetime.now().strftime("%Y%m%d_%H%M%S")}.json' + + response = HttpResponse(json_str, content_type='application/json') + response['Content-Disposition'] = f'attachment; filename="{filename}"' + return response + + +@login_required +def restore_data(request): + """Upload a .json backup to restore it into the current database. + + GET → renders a minimal upload form + warning + POST → accepts the file, validates, and loads it (inside a transaction) + + Admin-only. Requires explicit `confirm=yes` POST field to proceed, + so a stray click can't wipe production. + """ + if not is_admin(request.user): + return HttpResponseForbidden("Admin access required.") + + from core.management.commands.restore_data import ( + check_database_is_populated, restore_from_json_string, + ) + + db_has_data = check_database_is_populated() + + if request.method == 'POST': + if request.POST.get('confirm') != 'yes': + return HttpResponse( + '' + '

Restore cancelled

' + '

You must tick the "Yes, I understand" checkbox to proceed.

' + 'Back', + status=400, + ) + + uploaded = request.FILES.get('backup_file') + if not uploaded: + return HttpResponse( + '' + '

No file uploaded

' + 'Back', + status=400, + ) + + json_str = uploaded.read().decode('utf-8', errors='replace') + ok, result = restore_from_json_string(json_str) + + if not ok: + return HttpResponse( + '' + '

Restore failed

' + f'
{result}
' + 'Back', + status=500, + ) + + rows_html = '
'.join(f'{k}: {v}' for k, v in result.items()) + return HttpResponse( + '' + '

Restore complete!

' + f'
{rows_html}


' + 'Go to Dashboard' + ) + + # GET — render the upload form + warning_html = '' + if db_has_data: + warning_html = ( + '

' + 'Warning: this database already contains data ' + '(workers / work logs / payroll records). Restoring will UPDATE existing rows ' + 'by primary key and INSERT missing ones. This will NOT delete data that exists ' + 'in the DB but not in the backup. If you want a clean restore, run ' + 'python manage.py flush first (irreversible).' + '

' + ) + + return HttpResponse( + '' + '

Restore from backup

' + + warning_html + + '
' + f'' + '

' + '

' + '

' + ' Cancel

' + '
' + )