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>
This commit is contained in:
parent
a1ac8540ab
commit
0ace7c6786
15
.gitignore
vendored
15
.gitignore
vendored
@ -6,8 +6,23 @@ __pycache__/
|
|||||||
*.pyc
|
*.pyc
|
||||||
*.pyo
|
*.pyo
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
*.db
|
*.db
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
*.sqlite3-journal
|
||||||
.DS_Store
|
.DS_Store
|
||||||
media/
|
media/
|
||||||
.venv/
|
.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/
|
||||||
|
|||||||
@ -12,13 +12,40 @@ https://docs.djangoproject.com/en/5.2/ref/settings/
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import os
|
import os
|
||||||
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
load_dotenv(BASE_DIR.parent / ".env")
|
load_dotenv(BASE_DIR.parent / ".env")
|
||||||
|
|
||||||
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "change-me")
|
# === DEBUG ===
|
||||||
DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true"
|
# 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 = [
|
ALLOWED_HOSTS = [
|
||||||
"127.0.0.1",
|
"127.0.0.1",
|
||||||
@ -27,17 +54,29 @@ ALLOWED_HOSTS = [
|
|||||||
os.getenv("HOST_FQDN", ""),
|
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 = [
|
CSRF_TRUSTED_ORIGINS = [
|
||||||
origin for origin in [
|
origin for origin in [
|
||||||
"foxlog.flatlogic.app",
|
"foxlog.flatlogic.app",
|
||||||
os.getenv("HOST_FQDN", ""),
|
os.getenv("HOST_FQDN", ""),
|
||||||
os.getenv("CSRF_TRUSTED_ORIGIN", "")
|
os.getenv("CSRF_TRUSTED_ORIGIN", ""),
|
||||||
] if 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.
|
# Cookies must always be HTTPS-only; SameSite=Lax keeps CSRF working behind the proxy.
|
||||||
SESSION_COOKIE_SECURE = True
|
SESSION_COOKIE_SECURE = True
|
||||||
@ -160,29 +199,52 @@ MEDIA_URL = '/media/'
|
|||||||
MEDIA_ROOT = BASE_DIR / 'media'
|
MEDIA_ROOT = BASE_DIR / 'media'
|
||||||
|
|
||||||
# === EMAIL CONFIGURATION ===
|
# === EMAIL CONFIGURATION ===
|
||||||
# Uses Gmail SMTP with an App Password to send payslip PDFs and receipts.
|
# NO FALLBACKS for credentials — they MUST come from environment variables
|
||||||
# The App Password is a 16-character code from Google Account settings —
|
# (the Flatlogic .env file at `../.env`). Previous versions had the Gmail
|
||||||
# it lets the app send email through Gmail without your actual password.
|
# 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 = os.getenv(
|
||||||
"EMAIL_BACKEND",
|
"EMAIL_BACKEND",
|
||||||
"django.core.mail.backends.smtp.EmailBackend"
|
"django.core.mail.backends.smtp.EmailBackend"
|
||||||
)
|
)
|
||||||
EMAIL_HOST = os.getenv("EMAIL_HOST", "smtp.gmail.com")
|
EMAIL_HOST = os.getenv("EMAIL_HOST", "smtp.gmail.com")
|
||||||
EMAIL_PORT = int(os.getenv("EMAIL_PORT", "587"))
|
EMAIL_PORT = int(os.getenv("EMAIL_PORT", "587"))
|
||||||
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "konrad@foxfitt.co.za")
|
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "") # set via .env
|
||||||
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "cwvhpcwyijneukax")
|
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "") # set via .env
|
||||||
EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "true").lower() == "true"
|
EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "true").lower() == "true"
|
||||||
EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "false").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 = [
|
CONTACT_EMAIL_TO = [
|
||||||
item.strip()
|
item.strip()
|
||||||
for item in os.getenv("CONTACT_EMAIL_TO", DEFAULT_FROM_EMAIL).split(",")
|
for item in os.getenv("CONTACT_EMAIL_TO", DEFAULT_FROM_EMAIL).split(",")
|
||||||
if item.strip()
|
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")
|
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
|
# When both TLS and SSL flags are enabled, prefer SSL explicitly
|
||||||
if EMAIL_USE_SSL:
|
if EMAIL_USE_SSL:
|
||||||
EMAIL_USE_TLS = False
|
EMAIL_USE_TLS = False
|
||||||
|
|||||||
141
core/management/commands/backup_data.py
Normal file
141
core/management/commands/backup_data.py
Normal file
@ -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 <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}")
|
||||||
141
core/management/commands/restore_data.py
Normal file
141
core/management/commands/restore_data.py
Normal file
@ -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}")
|
||||||
18
core/migrations/0007_vat_type_default.py
Normal file
18
core/migrations/0007_vat_type_default.py
Normal file
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
core/migrations/0008_vat_type_default_none.py
Normal file
18
core/migrations/0008_vat_type_default_none.py
Normal file
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -195,7 +195,7 @@ class ExpenseReceipt(models.Model):
|
|||||||
vendor_name = models.CharField(max_length=200)
|
vendor_name = models.CharField(max_length=200)
|
||||||
description = models.TextField(blank=True)
|
description = models.TextField(blank=True)
|
||||||
payment_method = models.CharField(max_length=20, choices=METHOD_CHOICES)
|
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)
|
subtotal = models.DecimalField(max_digits=12, decimal_places=2)
|
||||||
vat_amount = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00'))
|
vat_amount = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00'))
|
||||||
total_amount = models.DecimalField(max_digits=12, decimal_places=2)
|
total_amount = models.DecimalField(max_digits=12, decimal_places=2)
|
||||||
|
|||||||
@ -70,4 +70,11 @@ urlpatterns = [
|
|||||||
# === TEMPORARY: Run migrations from browser ===
|
# === TEMPORARY: Run migrations from browser ===
|
||||||
# Visit /run-migrate/ to apply pending database migrations on production.
|
# Visit /run-migrate/ to apply pending database migrations on production.
|
||||||
path('run-migrate/', views.run_migrate, name='run_migrate'),
|
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'),
|
||||||
]
|
]
|
||||||
|
|||||||
117
core/views.py
117
core/views.py
@ -16,6 +16,7 @@ from django.db.models.functions import TruncMonth
|
|||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.http import JsonResponse, HttpResponseForbidden, HttpResponse
|
from django.http import JsonResponse, HttpResponseForbidden, HttpResponse
|
||||||
|
from django.middleware.csrf import get_token
|
||||||
from django.core.mail import EmailMultiAlternatives
|
from django.core.mail import EmailMultiAlternatives
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from django.utils.html import strip_tags
|
from django.utils.html import strip_tags
|
||||||
@ -2633,3 +2634,119 @@ def run_migrate(request):
|
|||||||
'</body></html>',
|
'</body></html>',
|
||||||
status=500,
|
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(
|
||||||
|
'<html><body style="font-family: monospace; padding: 20px; color: red;">'
|
||||||
|
'<h2>Restore cancelled</h2>'
|
||||||
|
'<p>You must tick the "Yes, I understand" checkbox to proceed.</p>'
|
||||||
|
'<a href="/restore-data/">Back</a></body></html>',
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
uploaded = request.FILES.get('backup_file')
|
||||||
|
if not uploaded:
|
||||||
|
return HttpResponse(
|
||||||
|
'<html><body style="font-family: monospace; padding: 20px; color: red;">'
|
||||||
|
'<h2>No file uploaded</h2>'
|
||||||
|
'<a href="/restore-data/">Back</a></body></html>',
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
json_str = uploaded.read().decode('utf-8', errors='replace')
|
||||||
|
ok, result = restore_from_json_string(json_str)
|
||||||
|
|
||||||
|
if not ok:
|
||||||
|
return HttpResponse(
|
||||||
|
'<html><body style="font-family: monospace; padding: 20px; color: red;">'
|
||||||
|
'<h2>Restore failed</h2>'
|
||||||
|
f'<pre>{result}</pre>'
|
||||||
|
'<a href="/restore-data/">Back</a></body></html>',
|
||||||
|
status=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
rows_html = '<br>'.join(f'{k}: {v}' for k, v in result.items())
|
||||||
|
return HttpResponse(
|
||||||
|
'<html><body style="font-family: monospace; padding: 20px;">'
|
||||||
|
'<h2 style="color: #10b981;">Restore complete!</h2>'
|
||||||
|
f'<div>{rows_html}</div><br><br>'
|
||||||
|
'<a href="/">Go to Dashboard</a></body></html>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# GET — render the upload form
|
||||||
|
warning_html = ''
|
||||||
|
if db_has_data:
|
||||||
|
warning_html = (
|
||||||
|
'<p style="color: #e8851a; border-left: 3px solid #e8851a; padding-left: 10px;">'
|
||||||
|
'<strong>Warning:</strong> 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 '
|
||||||
|
'<code>python manage.py flush</code> first (irreversible).'
|
||||||
|
'</p>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
'<html><body style="font-family: monospace; padding: 20px; max-width: 700px;">'
|
||||||
|
'<h2>Restore from backup</h2>'
|
||||||
|
+ warning_html +
|
||||||
|
'<form method="post" enctype="multipart/form-data">'
|
||||||
|
f'<input type="hidden" name="csrfmiddlewaretoken" value="{get_token(request)}">'
|
||||||
|
'<p><label>Backup JSON file:<br>'
|
||||||
|
'<input type="file" name="backup_file" accept="application/json" required></label></p>'
|
||||||
|
'<p><label><input type="checkbox" name="confirm" value="yes" required> '
|
||||||
|
'Yes, I understand this will overwrite matching rows in the database.</label></p>'
|
||||||
|
'<p><button type="submit" style="padding: 10px 20px; background: #e8851a; '
|
||||||
|
'color: white; border: none; border-radius: 4px; cursor: pointer;">'
|
||||||
|
'Restore</button>'
|
||||||
|
' <a href="/" style="margin-left: 10px;">Cancel</a></p>'
|
||||||
|
'</form></body></html>'
|
||||||
|
)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user