""" 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" # === FROM-ADDRESS === # Where outgoing emails appear to come from. If DEFAULT_FROM_EMAIL isn't # explicitly set, fall back to the Gmail address we authenticate as — # that's always a valid sender since it's the same account sending the email. # Without this fallback, emails fail with "Invalid address ''" if the # env var is missing, even though auth + SMTP are otherwise fine. DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "") or EMAIL_HOST_USER 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'