38686-vm/config/settings.py
Konrad du Plessis 3c28387dd3 WIP: 2026-04-22 session checkpoint
Complete working state of the session. Will be split into two deploy
phases (safety scaffolding then feature release) before merging to ai-dev.

Includes:
- Security fixes (email creds / SECRET_KEY / DEBUG / CSRF)
- Backup + restore management commands and browser endpoints
- WeasyPrint migration (replaces xhtml2pdf)
- New Worker fields + WorkerCertificate + WorkerWarning models
- Worker / Team / Project friendly management UIs
- Dashboard cert-expiry card + Manage All buttons
- Bootstrap tooltips (global init + theme-aware CSS)
- Django admin template override (taller M2M pickers)
- Money filter for ZAR currency formatting
- Resources dropdown nav
- Massive CLAUDE.md expansion + deploy plan docs

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

297 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Django settings for config project.
Generated by 'django-admin startproject' using Django 5.2.7.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/topics/settings/
For the full list of settings and their values, see
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")
# === 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. "
"Use `python -c \"import secrets; print(secrets.token_urlsafe(64))\"` "
"to generate a new one."
)
ALLOWED_HOSTS = [
"127.0.0.1",
"localhost",
"foxlog.flatlogic.app",
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", ""),
] if origin
]
def _normalize_origin(host):
"""Ensure `host` has an http:// or https:// scheme; default to https.
Accepts any of: 'example.com' / 'https://example.com' / 'http://localhost'
Returns a string with a scheme every time.
"""
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
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = "None"
CSRF_COOKIE_SAMESITE = "None"
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'core',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
# Disable X-Frame-Options middleware to allow Flatlogic preview iframes.
# 'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
X_FRAME_OPTIONS = 'ALLOWALL'
ROOT_URLCONF = 'config.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
# Explicitly load core/templates first so we can override specific
# Django admin templates (e.g. admin/base_site.html) without having
# to reorder INSTALLED_APPS. Without this entry, the app-dirs loader
# finds django.contrib.admin's version before ours.
'DIRS': [BASE_DIR / 'core' / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
# IMPORTANT: do not remove injects PROJECT_DESCRIPTION/PROJECT_IMAGE_URL and cache-busting timestamp
'core.context_processors.project_context',
],
},
},
]
WSGI_APPLICATION = 'config.wsgi.application'
# Database
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': os.getenv('DB_NAME', ''),
'USER': os.getenv('DB_USER', ''),
'PASSWORD': os.getenv('DB_PASS', ''),
'HOST': os.getenv('DB_HOST', '127.0.0.1'),
'PORT': os.getenv('DB_PORT', '3306'),
'OPTIONS': {
'charset': 'utf8mb4',
},
},
}
# Password validation
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'Africa/Johannesburg'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.2/howto/static-files/
STATIC_URL = 'static/'
# Collect static into a separate folder; avoid overlapping with STATICFILES_DIRS.
STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_DIRS = [
BASE_DIR / 'static',
BASE_DIR / 'node_modules',
]
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.
# === EMAIL CONFIGURATION ===
# NO FALLBACKS for credentials — they MUST come from environment variables.
# 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", "") # set on deploy platform
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "") # set on deploy platform
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", "")
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 routed here for accounting import.
# This is a routing address, not a secret, so a default is acceptable — but override
# via env var for flexibility. Set to empty string if you want to disable sending.
SPARK_RECEIPT_EMAIL = os.getenv("SPARK_RECEIPT_EMAIL", "foxfitt-ed9wc+expense@to.sparkreceipt.com")
# Fail loudly at startup in production if credentials 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:
# Don't crash — email sending isn't critical for the app to boot —
# but log a loud warning so it's visible in deploy logs.
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
# Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
LOGIN_URL = 'login'
LOGIN_REDIRECT_URL = 'home'
LOGOUT_REDIRECT_URL = 'login'
# === MESSAGE TAGS ===
# Django's messages.error() tags messages as "error", but Bootstrap uses "danger"
# for red alerts. Without this mapping, error messages would render as "alert-error"
# which doesn't exist in Bootstrap — making them invisible to the user!
from django.contrib.messages import constants as message_constants
MESSAGE_TAGS = {
message_constants.ERROR: 'danger',
}
# === LOCAL DEVELOPMENT: SQLite override ===
# Set USE_SQLITE=true in environment to use SQLite instead of MariaDB.
# This lets you test locally without a MySQL/MariaDB server.
if os.getenv('USE_SQLITE', 'false').lower() == 'true':
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
# Disable secure cookies for local http:// testing
SESSION_COOKIE_SECURE = False
CSRF_COOKIE_SECURE = False
SESSION_COOKIE_SAMESITE = 'Lax'
CSRF_COOKIE_SAMESITE = 'Lax'