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
17
.gitignore
vendored
17
.gitignore
vendored
@ -6,8 +6,23 @@ __pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.env
|
||||
.env.*
|
||||
*.db
|
||||
*.sqlite3
|
||||
*.sqlite3-journal
|
||||
.DS_Store
|
||||
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
|
||||
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
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
@ -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'),
|
||||
]
|
||||
|
||||
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.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):
|
||||
'</body></html>',
|
||||
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