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