Coverage for benefits / settings.py: 88%
134 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-01 15:39 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-01 15:39 +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_littlepay",
67 "benefits.enrollment_switchio",
68 "benefits.eligibility",
69 "benefits.oauth",
70 "benefits.in_person",
71]
73GOOGLE_SSO_CLIENT_ID = os.environ.get("GOOGLE_SSO_CLIENT_ID", "secret")
74GOOGLE_SSO_PROJECT_ID = os.environ.get("GOOGLE_SSO_PROJECT_ID", "benefits-admin")
75GOOGLE_SSO_CLIENT_SECRET = os.environ.get("GOOGLE_SSO_CLIENT_SECRET", "secret")
76GOOGLE_SSO_ALLOWABLE_DOMAINS = _filter_empty(os.environ.get("GOOGLE_SSO_ALLOWABLE_DOMAINS", "compiler.la").split(","))
77GOOGLE_SSO_STAFF_LIST = _filter_empty(os.environ.get("GOOGLE_SSO_STAFF_LIST", "").split(","))
78GOOGLE_SSO_SUPERUSER_LIST = _filter_empty(os.environ.get("GOOGLE_SSO_SUPERUSER_LIST", "").split(","))
79GOOGLE_SSO_LOGO_URL = "/static/img/icon/google_sso_logo.svg"
80GOOGLE_SSO_TEXT = "Log in with Google"
81GOOGLE_SSO_SAVE_ACCESS_TOKEN = True
82GOOGLE_SSO_PRE_LOGIN_CALLBACK = "benefits.core.admin.pre_login_user"
83GOOGLE_SSO_SCOPES = [
84 "openid",
85 "https://www.googleapis.com/auth/userinfo.email",
86 "https://www.googleapis.com/auth/userinfo.profile",
87]
88SSO_SHOW_FORM_ON_ADMIN_PAGE = os.environ.get("SSO_SHOW_FORM_ON_ADMIN_PAGE", "false").lower() == "true"
89STAFF_GROUP_NAME = "Cal-ITP"
91MIDDLEWARE = [
92 "django.middleware.security.SecurityMiddleware",
93 "django.contrib.sessions.middleware.SessionMiddleware",
94 "django.contrib.messages.middleware.MessageMiddleware",
95 "django.middleware.locale.LocaleMiddleware",
96 "benefits.core.middleware.Healthcheck",
97 "benefits.core.middleware.HealthcheckUserAgents",
98 "django.middleware.common.CommonMiddleware",
99 "django.middleware.csrf.CsrfViewMiddleware",
100 "django.middleware.clickjacking.XFrameOptionsMiddleware",
101 "csp.middleware.CSPMiddleware",
102 "benefits.core.middleware.ChangedLanguageEvent",
103 "django.contrib.auth.middleware.AuthenticationMiddleware",
104 "django.contrib.messages.middleware.MessageMiddleware",
105]
107if DEBUG: 107 ↛ 108line 107 didn't jump to line 108 because the condition on line 107 was never true
108 MIDDLEWARE.append("benefits.core.middleware.DebugSession")
110HEALTHCHECK_USER_AGENTS = _filter_empty(os.environ.get("HEALTHCHECK_USER_AGENTS", "").split(","))
112CSRF_COOKIE_AGE = None
113CSRF_COOKIE_SAMESITE = "Strict"
114CSRF_COOKIE_HTTPONLY = True
115CSRF_TRUSTED_ORIGINS = _filter_empty(os.environ.get("DJANGO_TRUSTED_ORIGINS", "http://localhost,http://127.0.0.1").split(","))
117# With `Strict`, the user loses their Django session between leaving our app to
118# sign in with OAuth, and coming back into our app from the OAuth redirect.
119# This is because `Strict` disallows our cookie being sent from an external
120# domain and so the session cookie is lost.
121#
122# `Lax` allows the cookie to travel with the user and be sent back to us by the
123# OAuth server, as long as the request is "safe" i.e. GET
124SESSION_COOKIE_SAMESITE = "Lax"
125SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies"
126SESSION_EXPIRE_AT_BROWSER_CLOSE = True
127SESSION_COOKIE_NAME = "_benefitssessionid"
129if not DEBUG: 129 ↛ 134line 129 didn't jump to line 134 because the condition on line 129 was always true
130 CSRF_COOKIE_SECURE = True
131 CSRF_FAILURE_VIEW = "benefits.views.csrf_failure_handler"
132 SESSION_COOKIE_SECURE = True
134SECURE_BROWSER_XSS_FILTER = True
136# required so that cross-origin pop-ups (like the enrollment overlay) have access to parent window context
137# https://github.com/cal-itp/benefits/pull/793
138SECURE_CROSS_ORIGIN_OPENER_POLICY = "same-origin-allow-popups"
140# the NGINX reverse proxy sits in front of the application in deployed environments
141# SSL terminates before getting to Django, and NGINX adds this header to indicate
142# if the original request was secure or not
143#
144# See https://docs.djangoproject.com/en/stable/ref/settings/#secure-proxy-ssl-header
145if not DEBUG: 145 ↛ 148line 145 didn't jump to line 148 because the condition on line 145 was always true
146 SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
148ROOT_URLCONF = "benefits.urls"
150template_ctx_processors = [
151 "django.template.context_processors.request",
152 "django.contrib.auth.context_processors.auth",
153 "django.contrib.messages.context_processors.messages",
154 "benefits.core.context_processors.agency",
155 "benefits.core.context_processors.active_agencies",
156 "benefits.core.context_processors.analytics",
157 "benefits.core.context_processors.authentication",
158 "benefits.core.context_processors.enrollment",
159 "benefits.core.context_processors.origin",
160 "benefits.core.context_processors.routes",
161 "benefits.core.context_processors.feature_flags",
162]
164if DEBUG: 164 ↛ 165line 164 didn't jump to line 165 because the condition on line 164 was never true
165 template_ctx_processors.extend(
166 [
167 "django.template.context_processors.debug",
168 "benefits.core.context_processors.debug",
169 ]
170 )
172TEMPLATES = [
173 {
174 "BACKEND": "django.template.backends.django.DjangoTemplates",
175 "DIRS": [os.path.join(BASE_DIR, "benefits", "templates")],
176 "APP_DIRS": True,
177 "OPTIONS": {
178 "context_processors": template_ctx_processors,
179 },
180 },
181]
183WSGI_APPLICATION = "benefits.wsgi.application"
185STORAGE_DIR = os.environ.get("DJANGO_STORAGE_DIR", BASE_DIR)
186USE_POSTGRES = os.environ.get("USE_POSTGRES", "false").lower() == "true"
188sslmode = os.environ.get("POSTGRES_SSLMODE", "verify-full")
189sslrootcert = "/etc/ssl/certs/ca-certificates.crt" if sslmode == "verify-full" else None
191if USE_POSTGRES: 191 ↛ 192line 191 didn't jump to line 192 because the condition on line 191 was never true
192 DATABASES = {
193 "default": {
194 "ENGINE": "django.db.backends.postgresql",
195 "NAME": os.environ.get("DJANGO_DB_NAME", "django"),
196 "USER": os.environ.get("DJANGO_DB_USER", "django"),
197 "PASSWORD": os.environ.get("DJANGO_DB_PASSWORD"),
198 "HOST": os.environ.get("POSTGRES_HOSTNAME", "postgres"),
199 "PORT": os.environ.get("POSTGRES_PORT", "5432"),
200 "OPTIONS": {
201 "sslmode": sslmode,
202 "sslrootcert": sslrootcert,
203 # add these lines to enable TCP keepalives --
204 # tiny network packets that keep the connection active and prevent
205 # network hardware or the database server from thinking it's idle
206 "keepalives": 1,
207 "keepalives_idle": 60,
208 "keepalives_interval": 10,
209 "keepalives_count": 5,
210 },
211 }
212 }
213else:
214 DATABASES = {
215 "default": {
216 "ENGINE": "django.db.backends.sqlite3",
217 "NAME": os.path.join(STORAGE_DIR, os.environ.get("DJANGO_DB_FILE", "django.db")),
218 }
219 }
221# Password handling
223AUTH_PASSWORD_VALIDATORS = [
224 {
225 "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
226 },
227 {
228 "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
229 },
230 {
231 "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
232 },
233 {
234 "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
235 },
236]
237PASSWORD_RESET_TIMEOUT = 86400 # 24 hours, in seconds
240# Internationalization
242LANGUAGE_CODE = "en"
244LANGUAGE_COOKIE_HTTPONLY = True
245# `Lax` allows the cookie to travel with the user and be sent back to Benefits
246# during redirection e.g. through IdG/Login.gov or a Transit Processor portal
247# ensuring the app is displayed in the same language
248LANGUAGE_COOKIE_SAMESITE = "Lax"
249LANGUAGE_COOKIE_SECURE = True
251LANGUAGES = [("en", "English"), ("es", "Español")]
253LOCALE_PATHS = [os.path.join(BASE_DIR, "benefits", "locale")]
255USE_I18N = True
257# See https://docs.djangoproject.com/en/stable/ref/settings/#std-setting-TIME_ZONE
258# > Note that this isn’t necessarily the time zone of the server.
259# > When USE_TZ is True, this is the default time zone that Django will use to display datetimes in templates
260# > and to interpret datetimes entered in forms.
261TIME_ZONE = "America/Los_Angeles"
262USE_TZ = True
264# https://docs.djangoproject.com/en/stable/topics/i18n/formatting/#creating-custom-format-files
265FORMAT_MODULE_PATH = [
266 "benefits.locale",
267]
269# Static files (CSS, JavaScript, Images)
271STATIC_URL = "/static/"
272STATICFILES_DIRS = [os.path.join(BASE_DIR, "benefits", "static")]
273# use Manifest Static Files Storage by default
274STORAGES = {
275 "default": {
276 "BACKEND": "django.core.files.storage.FileSystemStorage",
277 },
278 "staticfiles": {
279 "BACKEND": os.environ.get(
280 "DJANGO_STATICFILES_STORAGE", "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"
281 )
282 },
283}
284STATIC_ROOT = os.path.join(BASE_DIR, "static")
286# User-uploaded files
288MEDIA_ROOT = os.path.join(STORAGE_DIR, "uploads/")
290MEDIA_URL = "/media/"
292# Logging configuration
293LOG_LEVEL = os.environ.get("DJANGO_LOG_LEVEL", "DEBUG" if DEBUG else "WARNING")
294LOGGING = {
295 "version": 1,
296 "disable_existing_loggers": False,
297 "formatters": {
298 "default": {
299 "format": "[{asctime}] {levelname} {name}:{lineno} {message}",
300 "datefmt": "%d/%b/%Y %H:%M:%S",
301 "style": "{",
302 },
303 },
304 "handlers": {
305 "console": {
306 "class": "logging.StreamHandler",
307 "formatter": "default",
308 },
309 },
310 "root": {
311 "handlers": ["console"],
312 "level": LOG_LEVEL,
313 },
314 "loggers": {
315 "django": {
316 "handlers": ["console"],
317 "propagate": False,
318 },
319 },
320}
322sentry.configure(RUNTIME_ENVIRONMENT())
324# Analytics configuration
326ANALYTICS_KEY = os.environ.get("ANALYTICS_KEY")
328# reCAPTCHA configuration
330RECAPTCHA_API_URL = os.environ.get("DJANGO_RECAPTCHA_API_URL", "https://www.google.com/recaptcha/api.js")
331RECAPTCHA_SITE_KEY = os.environ.get("DJANGO_RECAPTCHA_SITE_KEY")
332RECAPTCHA_API_KEY_URL = f"{RECAPTCHA_API_URL}?render={RECAPTCHA_SITE_KEY}"
333RECAPTCHA_SECRET_KEY = os.environ.get("DJANGO_RECAPTCHA_SECRET_KEY")
334RECAPTCHA_VERIFY_URL = os.environ.get("DJANGO_RECAPTCHA_VERIFY_URL", "https://www.google.com/recaptcha/api/siteverify")
335RECAPTCHA_ENABLED = all((RECAPTCHA_API_URL, RECAPTCHA_SITE_KEY, RECAPTCHA_SECRET_KEY, RECAPTCHA_VERIFY_URL))
337# Content Security Policy
338# Configuration docs at https://django-csp.readthedocs.io/en/latest/configuration.html+
340CONTENT_SECURITY_POLICY = {
341 "DIRECTIVES": {
342 "base-uri": [NONE],
343 "connect-src": [
344 SELF,
345 "https://api.amplitude.com/",
346 "https://cdn.jsdelivr.net/npm/@switchio",
347 "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/",
348 "https://cdn.jsdelivr.net/npm/jquery",
349 ],
350 "default-src": [SELF],
351 "font-src": [SELF, "https://fonts.gstatic.com/"],
352 "frame-ancestors": [NONE],
353 "frame-src": ["*.littlepay.com"],
354 "img-src": [SELF, "data:", "*.googleusercontent.com"],
355 "object-src": [NONE],
356 "script-src": [
357 SELF,
358 "https://cdn.amplitude.com/libs/",
359 "https://cdn.jsdelivr.net/npm/@switchio",
360 "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/",
361 "https://cdn.jsdelivr.net/npm/jquery",
362 "*.littlepay.com",
363 NONCE, # https://django-csp.readthedocs.io/en/latest/nonce.html
364 ],
365 "style-src": [
366 SELF,
367 "https://fonts.googleapis.com/css",
368 "https://fonts.googleapis.com/css2",
369 "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/",
370 ],
371 }
372}
374# connect-src additions
375env_connect_src = _filter_empty(os.environ.get("DJANGO_CSP_CONNECT_SRC", "").split(","))
376if RECAPTCHA_ENABLED: 376 ↛ 377line 376 didn't jump to line 377 because the condition on line 376 was never true
377 env_connect_src.append("https://www.google.com/recaptcha/")
378CONTENT_SECURITY_POLICY["DIRECTIVES"]["connect-src"].extend(env_connect_src)
380# font-src additions
381env_font_src = _filter_empty(os.environ.get("DJANGO_CSP_FONT_SRC", "").split(","))
382CONTENT_SECURITY_POLICY["DIRECTIVES"]["font-src"].extend(env_font_src)
384# frame-src additions
385env_frame_src = _filter_empty(os.environ.get("DJANGO_CSP_FRAME_SRC", "").split(","))
386if RECAPTCHA_ENABLED: 386 ↛ 387line 386 didn't jump to line 387 because the condition on line 386 was never true
387 env_frame_src.append("https://www.google.com")
388CONTENT_SECURITY_POLICY["DIRECTIVES"]["frame-src"].extend(env_frame_src)
390# script-src additions
391env_script_src = _filter_empty(os.environ.get("DJANGO_CSP_SCRIPT_SRC", "").split(","))
392if RECAPTCHA_ENABLED: 392 ↛ 393line 392 didn't jump to line 393 because the condition on line 392 was never true
393 env_script_src.extend(["https://www.google.com/recaptcha/", "https://www.gstatic.com/recaptcha/releases/"])
394CONTENT_SECURITY_POLICY["DIRECTIVES"]["script-src"].extend(env_script_src)
396# style-src additions
397env_style_src = _filter_empty(os.environ.get("DJANGO_CSP_STYLE_SRC", "").split(","))
398CONTENT_SECURITY_POLICY["DIRECTIVES"]["style-src"].extend(env_style_src)
400# adjust report-uri when using Sentry
401if sentry.SENTRY_CSP_REPORT_URI: 401 ↛ 402line 401 didn't jump to line 402 because the condition on line 401 was never true
402 CONTENT_SECURITY_POLICY["DIRECTIVES"]["report-uri"] = sentry.SENTRY_CSP_REPORT_URI
405# Configuration for requests
406# https://requests.readthedocs.io/en/latest/user/advanced/#timeouts
408try:
409 REQUESTS_CONNECT_TIMEOUT = int(os.environ.get("REQUESTS_CONNECT_TIMEOUT"))
410except Exception:
411 REQUESTS_CONNECT_TIMEOUT = 3
413try:
414 REQUESTS_READ_TIMEOUT = int(os.environ.get("REQUESTS_READ_TIMEOUT"))
415except Exception:
416 REQUESTS_READ_TIMEOUT = 20
418REQUESTS_TIMEOUT = (REQUESTS_CONNECT_TIMEOUT, REQUESTS_READ_TIMEOUT)
420# Email
421# https://docs.djangoproject.com/en/stable/ref/settings/#email-backend
422# https://github.com/retech-us/django-azure-communication-email
423AZURE_COMMUNICATION_CONNECTION_STRING = os.environ.get("AZURE_COMMUNICATION_CONNECTION_STRING")
425if AZURE_COMMUNICATION_CONNECTION_STRING: 425 ↛ 426line 425 didn't jump to line 426 because the condition on line 425 was never true
426 EMAIL_BACKEND = "django_azure_communication_email.EmailBackend"
427 EMAIL_USE_TLS = True
428else:
429 EMAIL_BACKEND = "django.core.mail.backends.filebased.EmailBackend"
430 EMAIL_FILE_PATH = os.path.join(STORAGE_DIR, ".sent_emails")
432# https://docs.djangoproject.com/en/stable/ref/settings/#default-from-email
433DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "noreply@example.calitp.org")