Coverage for benefits/settings.py: 87%
139 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-08 19:50 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-08 19:50 +0000
1"""
2Django settings for benefits project.
3"""
5import os
7from csp.constants import NONCE, NONE, SELF
8from django.conf import settings
10from benefits import sentry
13def _filter_empty(ls):
14 return [s for s in ls if s]
17# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
18BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
20# SECURITY WARNING: keep the secret key used in production secret!
21SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "secret")
23# SECURITY WARNING: don't run with debug turned on in production!
24DEBUG = os.environ.get("DJANGO_DEBUG", "false").lower() == "true"
26ALLOWED_HOSTS = _filter_empty(os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost").split(","))
29class RUNTIME_ENVS:
30 LOCAL = "local"
31 DEV = "dev"
32 TEST = "test"
33 PROD = "prod"
36def RUNTIME_ENVIRONMENT():
37 """Helper calculates the current runtime environment from ALLOWED_HOSTS."""
39 # usage of django.conf.settings.ALLOWED_HOSTS here (rather than the module variable directly)
40 # is to ensure dynamic calculation, e.g. for unit tests and elsewhere this setting is needed
41 env = RUNTIME_ENVS.LOCAL
42 if "dev-benefits.calitp.org" in settings.ALLOWED_HOSTS:
43 env = RUNTIME_ENVS.DEV
44 elif "test-benefits.calitp.org" in settings.ALLOWED_HOSTS:
45 env = RUNTIME_ENVS.TEST
46 elif "benefits.calitp.org" in settings.ALLOWED_HOSTS:
47 env = RUNTIME_ENVS.PROD
48 return env
51# Application definition
53INSTALLED_APPS = [
54 "benefits.apps.BenefitsAdminConfig",
55 "django.contrib.auth",
56 "django.contrib.contenttypes",
57 "django.contrib.messages",
58 "django.contrib.sessions",
59 "django.contrib.staticfiles",
60 "csp",
61 "adminsortable2",
62 "cdt_identity",
63 "django_google_sso",
64 "benefits.core",
65 "benefits.enrollment",
66 "benefits.enrollment_init",
67 "benefits.enrollment_littlepay",
68 "benefits.enrollment_switchio",
69 "benefits.eligibility",
70 "benefits.oauth",
71 "benefits.in_person",
72 "benefits.metro_mobility_wallet",
73]
75GOOGLE_SSO_CLIENT_ID = os.environ.get("GOOGLE_SSO_CLIENT_ID", "secret")
76GOOGLE_SSO_PROJECT_ID = os.environ.get("GOOGLE_SSO_PROJECT_ID", "benefits-admin")
77GOOGLE_SSO_CLIENT_SECRET = os.environ.get("GOOGLE_SSO_CLIENT_SECRET", "secret")
78GOOGLE_SSO_ALLOWABLE_DOMAINS = _filter_empty(os.environ.get("GOOGLE_SSO_ALLOWABLE_DOMAINS", "compiler.la").split(","))
79GOOGLE_SSO_STAFF_LIST = _filter_empty(os.environ.get("GOOGLE_SSO_STAFF_LIST", "").split(","))
80GOOGLE_SSO_SUPERUSER_LIST = _filter_empty(os.environ.get("GOOGLE_SSO_SUPERUSER_LIST", "").split(","))
81GOOGLE_SSO_LOGO_URL = "/static/img/icon/google_sso_logo.svg"
82GOOGLE_SSO_TEXT = "Log in with Google"
83GOOGLE_SSO_SAVE_ACCESS_TOKEN = True
84GOOGLE_SSO_PRE_LOGIN_CALLBACK = "benefits.core.admin.pre_login_user"
85GOOGLE_SSO_SCOPES = [
86 "openid",
87 "https://www.googleapis.com/auth/userinfo.email",
88 "https://www.googleapis.com/auth/userinfo.profile",
89]
90SSO_SHOW_FORM_ON_ADMIN_PAGE = os.environ.get("SSO_SHOW_FORM_ON_ADMIN_PAGE", "false").lower() == "true"
91STAFF_GROUP_NAME = "Cal-ITP"
93MIDDLEWARE = [
94 "django.middleware.security.SecurityMiddleware",
95 "django.contrib.sessions.middleware.SessionMiddleware",
96 "django.contrib.messages.middleware.MessageMiddleware",
97 "django.middleware.locale.LocaleMiddleware",
98 "benefits.core.middleware.ResetUnsupportedLanguage",
99 "benefits.core.middleware.Healthcheck",
100 "benefits.core.middleware.HealthcheckUserAgents",
101 "django.middleware.common.CommonMiddleware",
102 "django.middleware.csrf.CsrfViewMiddleware",
103 "django.middleware.clickjacking.XFrameOptionsMiddleware",
104 "csp.middleware.CSPMiddleware",
105 "benefits.core.middleware.ChangedLanguageEvent",
106 "django.contrib.auth.middleware.AuthenticationMiddleware",
107 "django.contrib.messages.middleware.MessageMiddleware",
108]
110if DEBUG: 110 ↛ 111line 110 didn't jump to line 111 because the condition on line 110 was never true
111 MIDDLEWARE.append("benefits.core.middleware.DebugSession")
113# The Django Debug Toolbar can be toggled on/off but in both cases the application has to be in debug mode
114DEBUG_TOOLBAR = DEBUG and os.environ.get("DJANGO_DEBUG_TOOLBAR", "false").lower() == "true"
115if DEBUG_TOOLBAR: 115 ↛ 116line 115 didn't jump to line 116 because the condition on line 115 was never true
116 INSTALLED_APPS.append("debug_toolbar")
117 MIDDLEWARE.insert(0, "debug_toolbar.middleware.DebugToolbarMiddleware")
118 # Show the toolbar when in local development mode and with debug on
119 DEBUG_TOOLBAR_CONFIG = {
120 "SHOW_TOOLBAR_CALLBACK": lambda request: settings.DEBUG_TOOLBAR,
121 }
124HEALTHCHECK_USER_AGENTS = _filter_empty(os.environ.get("HEALTHCHECK_USER_AGENTS", "").split(","))
126CSRF_COOKIE_AGE = None
127CSRF_COOKIE_SAMESITE = "Strict"
128CSRF_COOKIE_HTTPONLY = True
129CSRF_TRUSTED_ORIGINS = _filter_empty(os.environ.get("DJANGO_TRUSTED_ORIGINS", "http://localhost,http://127.0.0.1").split(","))
131# With `Strict`, the user loses their Django session between leaving our app to
132# sign in with OAuth, and coming back into our app from the OAuth redirect.
133# This is because `Strict` disallows our cookie being sent from an external
134# domain and so the session cookie is lost.
135#
136# `Lax` allows the cookie to travel with the user and be sent back to us by the
137# OAuth server, as long as the request is "safe" i.e. GET
138SESSION_COOKIE_SAMESITE = "Lax"
139SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies"
140SESSION_EXPIRE_AT_BROWSER_CLOSE = True
141SESSION_COOKIE_NAME = "_benefitssessionid"
143if not DEBUG: 143 ↛ 148line 143 didn't jump to line 148 because the condition on line 143 was always true
144 CSRF_COOKIE_SECURE = True
145 CSRF_FAILURE_VIEW = "benefits.views.csrf_failure_handler"
146 SESSION_COOKIE_SECURE = True
148SECURE_BROWSER_XSS_FILTER = True
150# required so that cross-origin pop-ups (like the enrollment overlay) have access to parent window context
151# https://github.com/cal-itp/benefits/pull/793
152SECURE_CROSS_ORIGIN_OPENER_POLICY = "same-origin-allow-popups"
154# the NGINX reverse proxy sits in front of the application in deployed environments
155# SSL terminates before getting to Django, and NGINX adds this header to indicate
156# if the original request was secure or not
157#
158# See https://docs.djangoproject.com/en/stable/ref/settings/#secure-proxy-ssl-header
159if not DEBUG: 159 ↛ 162line 159 didn't jump to line 162 because the condition on line 159 was always true
160 SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
162ROOT_URLCONF = "benefits.urls"
164template_ctx_processors = [
165 "django.template.context_processors.request",
166 "django.contrib.auth.context_processors.auth",
167 "django.contrib.messages.context_processors.messages",
168 "benefits.core.context_processors.agency",
169 "benefits.core.context_processors.active_agencies",
170 "benefits.core.context_processors.analytics",
171 "benefits.core.context_processors.authentication",
172 "benefits.core.context_processors.enrollment",
173 "benefits.core.context_processors.is_prod",
174 "benefits.core.context_processors.origin",
175 "benefits.core.context_processors.routes",
176 "benefits.core.context_processors.runtime_env",
177 "benefits.core.context_processors.feature_flags",
178]
180if DEBUG: 180 ↛ 181line 180 didn't jump to line 181 because the condition on line 180 was never true
181 template_ctx_processors.extend(
182 [
183 "django.template.context_processors.debug",
184 "benefits.core.context_processors.debug",
185 ]
186 )
188TEMPLATES = [
189 {
190 "BACKEND": "django.template.backends.django.DjangoTemplates",
191 "DIRS": [os.path.join(BASE_DIR, "benefits", "templates")],
192 "APP_DIRS": True,
193 "OPTIONS": {
194 "context_processors": template_ctx_processors,
195 },
196 },
197]
199WSGI_APPLICATION = "benefits.wsgi.application"
201STORAGE_DIR = os.environ.get("DJANGO_STORAGE_DIR", BASE_DIR)
203sslmode = os.environ.get("POSTGRES_SSLMODE", "verify-full")
204sslrootcert = "/etc/ssl/certs/ca-certificates.crt" if sslmode == "verify-full" else None
206DATABASES = {
207 "default": {
208 "ENGINE": "django.db.backends.postgresql",
209 "NAME": os.environ.get("DJANGO_DB_NAME", "django"),
210 "USER": os.environ.get("DJANGO_DB_USER", "django"),
211 "PASSWORD": os.environ.get("DJANGO_DB_PASSWORD"),
212 "HOST": os.environ.get("POSTGRES_HOSTNAME", "postgres"),
213 "PORT": os.environ.get("POSTGRES_PORT", "5432"),
214 "OPTIONS": {
215 "sslmode": sslmode,
216 "sslrootcert": sslrootcert,
217 # add these lines to enable TCP keepalives --
218 # tiny network packets that keep the connection active and prevent
219 # network hardware or the database server from thinking it's idle
220 "keepalives": 1,
221 "keepalives_idle": 60,
222 "keepalives_interval": 10,
223 "keepalives_count": 5,
224 },
225 }
226}
229# Password handling
231AUTH_PASSWORD_VALIDATORS = [
232 {
233 "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
234 },
235 {
236 "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
237 },
238 {
239 "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
240 },
241 {
242 "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
243 },
244]
245PASSWORD_RESET_TIMEOUT = 86400 # 24 hours, in seconds
248# Internationalization
250LANGUAGE_CODE = "en"
252LANGUAGE_COOKIE_HTTPONLY = True
253# `Lax` allows the cookie to travel with the user and be sent back to Benefits
254# during redirection e.g. through IdG/Login.gov or a Transit Processor portal
255# ensuring the app is displayed in the same language
256LANGUAGE_COOKIE_SAMESITE = "Lax"
257LANGUAGE_COOKIE_SECURE = True
259LANGUAGES_CORE = [("en", "English"), ("es", "Español")]
260LANGUAGES_METRO = [
261 ("zh-hans", "Chinese simplified"),
262 ("zh-hant", "Chinese traditional"),
263 ("ko", "Korean"),
264 ("ja", "Japanese"),
265 ("vi", "Vietnamese"),
266 ("th", "Thai"),
267 ("ru", "Russian"),
268 ("hy", "Armenian"),
269]
271LANGUAGES = LANGUAGES_CORE + LANGUAGES_METRO
273LOCALE_PATHS = [os.path.join(BASE_DIR, "benefits", "locale")]
275USE_I18N = True
277# See https://docs.djangoproject.com/en/stable/ref/settings/#std-setting-TIME_ZONE
278# > Note that this isn’t necessarily the time zone of the server.
279# > When USE_TZ is True, this is the default time zone that Django will use to display datetimes in templates
280# > and to interpret datetimes entered in forms.
281TIME_ZONE = "America/Los_Angeles"
282USE_TZ = True
284# https://docs.djangoproject.com/en/stable/topics/i18n/formatting/#creating-custom-format-files
285FORMAT_MODULE_PATH = [
286 "benefits.locale",
287]
289# Static files (CSS, JavaScript, Images)
291STATIC_URL = "/static/"
292STATICFILES_DIRS = [os.path.join(BASE_DIR, "benefits", "static")]
293# use Manifest Static Files Storage by default
294STORAGES = {
295 "default": {
296 "BACKEND": "django.core.files.storage.FileSystemStorage",
297 },
298 "staticfiles": {
299 "BACKEND": os.environ.get(
300 "DJANGO_STATICFILES_STORAGE", "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"
301 )
302 },
303}
304STATIC_ROOT = os.path.join(BASE_DIR, "static")
306# User-uploaded files
308MEDIA_ROOT = os.path.join(STORAGE_DIR, "uploads/")
310MEDIA_URL = "/media/"
312# Prevent Django from attempting to use os.chmod() when saving agency logos since it causes permission errors
313# on the Container App's Azure File Share
314FILE_UPLOAD_PERMISSIONS = None
316# Logging configuration
317LOG_LEVEL = os.environ.get("DJANGO_LOG_LEVEL", "DEBUG" if DEBUG else "WARNING")
318LOGGING = {
319 "version": 1,
320 "disable_existing_loggers": False,
321 "formatters": {
322 "default": {
323 "format": "[{asctime}] {levelname} {name}:{lineno} {message}",
324 "datefmt": "%d/%b/%Y %H:%M:%S",
325 "style": "{",
326 },
327 },
328 "handlers": {
329 "console": {
330 "class": "logging.StreamHandler",
331 "formatter": "default",
332 },
333 },
334 "root": {
335 "handlers": ["console"],
336 "level": LOG_LEVEL,
337 },
338 "loggers": {
339 "django": {
340 "handlers": ["console"],
341 "propagate": False,
342 },
343 },
344}
346sentry.configure(RUNTIME_ENVIRONMENT())
348# Analytics configuration
350ANALYTICS_KEY = os.environ.get("ANALYTICS_KEY")
352# reCAPTCHA configuration
354RECAPTCHA_API_URL = os.environ.get("DJANGO_RECAPTCHA_API_URL", "https://www.google.com/recaptcha/api.js")
355RECAPTCHA_SITE_KEY = os.environ.get("DJANGO_RECAPTCHA_SITE_KEY")
356RECAPTCHA_API_KEY_URL = f"{RECAPTCHA_API_URL}?render={RECAPTCHA_SITE_KEY}"
357RECAPTCHA_SECRET_KEY = os.environ.get("DJANGO_RECAPTCHA_SECRET_KEY")
358RECAPTCHA_VERIFY_URL = os.environ.get("DJANGO_RECAPTCHA_VERIFY_URL", "https://www.google.com/recaptcha/api/siteverify")
359RECAPTCHA_ENABLED = all((RECAPTCHA_API_URL, RECAPTCHA_SITE_KEY, RECAPTCHA_SECRET_KEY, RECAPTCHA_VERIFY_URL))
361# Content Security Policy
362# Configuration docs at https://django-csp.readthedocs.io/en/latest/configuration.html+
364CONTENT_SECURITY_POLICY = {
365 "DIRECTIVES": {
366 "base-uri": [NONE],
367 "connect-src": [
368 SELF,
369 "https://api.amplitude.com/",
370 "https://cdn.jsdelivr.net/sm/", # centralized sourcemap hosting
371 "https://cdn.jsdelivr.net/npm/@switchio",
372 "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/",
373 "https://cdn.jsdelivr.net/npm/jquery",
374 "https://secure.nmi.com",
375 ],
376 "default-src": [SELF],
377 "font-src": [SELF, "https://fonts.gstatic.com/"],
378 "frame-ancestors": [NONE],
379 "frame-src": ["*.littlepay.com", "https://secure.nmi.com"],
380 "img-src": [SELF, "data:", "*.googleusercontent.com", "https://secure.nmi.com"],
381 "object-src": [NONE],
382 "script-src": [
383 SELF,
384 "https://cdn.amplitude.com/libs/",
385 "https://cdn.jsdelivr.net/npm/@switchio",
386 "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/",
387 "https://cdn.jsdelivr.net/npm/jquery",
388 "*.littlepay.com",
389 "https://secure.nmi.com",
390 NONCE, # https://django-csp.readthedocs.io/en/latest/nonce.html
391 ],
392 "style-src": [
393 SELF,
394 "https://fonts.googleapis.com/css",
395 "https://fonts.googleapis.com/css2",
396 "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/",
397 "https://secure.nmi.com",
398 ],
399 }
400}
402# connect-src additions
403env_connect_src = _filter_empty(os.environ.get("DJANGO_CSP_CONNECT_SRC", "").split(","))
404if RECAPTCHA_ENABLED: 404 ↛ 405line 404 didn't jump to line 405 because the condition on line 404 was never true
405 env_connect_src.append("https://www.google.com/recaptcha/")
406CONTENT_SECURITY_POLICY["DIRECTIVES"]["connect-src"].extend(env_connect_src)
408# font-src additions
409env_font_src = _filter_empty(os.environ.get("DJANGO_CSP_FONT_SRC", "").split(","))
410CONTENT_SECURITY_POLICY["DIRECTIVES"]["font-src"].extend(env_font_src)
412# frame-src additions
413env_frame_src = _filter_empty(os.environ.get("DJANGO_CSP_FRAME_SRC", "").split(","))
414if RECAPTCHA_ENABLED: 414 ↛ 415line 414 didn't jump to line 415 because the condition on line 414 was never true
415 env_frame_src.append("https://www.google.com")
416CONTENT_SECURITY_POLICY["DIRECTIVES"]["frame-src"].extend(env_frame_src)
418# script-src additions
419env_script_src = _filter_empty(os.environ.get("DJANGO_CSP_SCRIPT_SRC", "").split(","))
420if RECAPTCHA_ENABLED: 420 ↛ 421line 420 didn't jump to line 421 because the condition on line 420 was never true
421 env_script_src.extend(["https://www.google.com/recaptcha/", "https://www.gstatic.com/recaptcha/releases/"])
422CONTENT_SECURITY_POLICY["DIRECTIVES"]["script-src"].extend(env_script_src)
424# style-src additions
425env_style_src = _filter_empty(os.environ.get("DJANGO_CSP_STYLE_SRC", "").split(","))
426CONTENT_SECURITY_POLICY["DIRECTIVES"]["style-src"].extend(env_style_src)
428# adjust report-uri when using Sentry
429if sentry.SENTRY_CSP_REPORT_URI: 429 ↛ 430line 429 didn't jump to line 430 because the condition on line 429 was never true
430 CONTENT_SECURITY_POLICY["DIRECTIVES"]["report-uri"] = sentry.SENTRY_CSP_REPORT_URI
433# Configuration for requests
434# https://requests.readthedocs.io/en/latest/user/advanced/#timeouts
436try:
437 REQUESTS_CONNECT_TIMEOUT = int(os.environ.get("REQUESTS_CONNECT_TIMEOUT"))
438except Exception:
439 REQUESTS_CONNECT_TIMEOUT = 3
441try:
442 REQUESTS_READ_TIMEOUT = int(os.environ.get("REQUESTS_READ_TIMEOUT"))
443except Exception:
444 REQUESTS_READ_TIMEOUT = 20
446REQUESTS_TIMEOUT = (REQUESTS_CONNECT_TIMEOUT, REQUESTS_READ_TIMEOUT)
448# Email
449# https://docs.djangoproject.com/en/stable/ref/settings/#email-backend
450# https://github.com/retech-us/django-azure-communication-email
451AZURE_COMMUNICATION_CONNECTION_STRING = os.environ.get("AZURE_COMMUNICATION_CONNECTION_STRING")
453if AZURE_COMMUNICATION_CONNECTION_STRING: 453 ↛ 454line 453 didn't jump to line 454 because the condition on line 453 was never true
454 EMAIL_BACKEND = "django_azure_communication_email.EmailBackend"
455 EMAIL_USE_TLS = True
456else:
457 EMAIL_BACKEND = "django.core.mail.backends.filebased.EmailBackend"
458 EMAIL_FILE_PATH = os.path.join(STORAGE_DIR, ".sent_emails")
460# https://docs.djangoproject.com/en/stable/ref/settings/#default-from-email
461DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "noreply@example.calitp.org")