Coverage for benefits / settings.py: 89%
129 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-22 19:08 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-22 19:08 +0000
1"""
2Django settings for benefits project.
3"""
5import os
7from django.conf import settings
9from csp.constants import NONCE, NONE, SELF
11from benefits import sentry
14def _filter_empty(ls):
15 return [s for s in ls if s]
18# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
19BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
21# SECURITY WARNING: keep the secret key used in production secret!
22SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "secret")
24# SECURITY WARNING: don't run with debug turned on in production!
25DEBUG = os.environ.get("DJANGO_DEBUG", "False").lower() == "true"
27ALLOWED_HOSTS = _filter_empty(os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost").split(","))
30class RUNTIME_ENVS:
31 LOCAL = "local"
32 DEV = "dev"
33 TEST = "test"
34 PROD = "prod"
37def RUNTIME_ENVIRONMENT():
38 """Helper calculates the current runtime environment from ALLOWED_HOSTS."""
40 # usage of django.conf.settings.ALLOWED_HOSTS here (rather than the module variable directly)
41 # is to ensure dynamic calculation, e.g. for unit tests and elsewhere this setting is needed
42 env = RUNTIME_ENVS.LOCAL
43 if "dev-benefits.calitp.org" in settings.ALLOWED_HOSTS:
44 env = RUNTIME_ENVS.DEV
45 elif "test-benefits.calitp.org" in settings.ALLOWED_HOSTS:
46 env = RUNTIME_ENVS.TEST
47 elif "benefits.calitp.org" in settings.ALLOWED_HOSTS:
48 env = RUNTIME_ENVS.PROD
49 return env
52# Application definition
54INSTALLED_APPS = [
55 "benefits.apps.BenefitsAdminConfig",
56 "django.contrib.auth",
57 "django.contrib.contenttypes",
58 "django.contrib.messages",
59 "django.contrib.sessions",
60 "django.contrib.staticfiles",
61 "csp",
62 "adminsortable2",
63 "cdt_identity",
64 "django_google_sso",
65 "benefits.core",
66 "benefits.enrollment",
67 "benefits.enrollment_littlepay",
68 "benefits.enrollment_switchio",
69 "benefits.eligibility",
70 "benefits.oauth",
71 "benefits.in_person",
72]
74GOOGLE_SSO_CLIENT_ID = os.environ.get("GOOGLE_SSO_CLIENT_ID", "secret")
75GOOGLE_SSO_PROJECT_ID = os.environ.get("GOOGLE_SSO_PROJECT_ID", "benefits-admin")
76GOOGLE_SSO_CLIENT_SECRET = os.environ.get("GOOGLE_SSO_CLIENT_SECRET", "secret")
77GOOGLE_SSO_ALLOWABLE_DOMAINS = _filter_empty(os.environ.get("GOOGLE_SSO_ALLOWABLE_DOMAINS", "compiler.la").split(","))
78GOOGLE_SSO_STAFF_LIST = _filter_empty(os.environ.get("GOOGLE_SSO_STAFF_LIST", "").split(","))
79GOOGLE_SSO_SUPERUSER_LIST = _filter_empty(os.environ.get("GOOGLE_SSO_SUPERUSER_LIST", "").split(","))
80GOOGLE_SSO_LOGO_URL = "/static/img/icon/google_sso_logo.svg"
81GOOGLE_SSO_TEXT = "Log in with Google"
82GOOGLE_SSO_SAVE_ACCESS_TOKEN = True
83GOOGLE_SSO_PRE_LOGIN_CALLBACK = "benefits.core.admin.pre_login_user"
84GOOGLE_SSO_SCOPES = [
85 "openid",
86 "https://www.googleapis.com/auth/userinfo.email",
87 "https://www.googleapis.com/auth/userinfo.profile",
88]
89SSO_SHOW_FORM_ON_ADMIN_PAGE = os.environ.get("SSO_SHOW_FORM_ON_ADMIN_PAGE", "False").lower() == "true"
90STAFF_GROUP_NAME = "Cal-ITP"
92MIDDLEWARE = [
93 "django.middleware.security.SecurityMiddleware",
94 "django.contrib.sessions.middleware.SessionMiddleware",
95 "django.contrib.messages.middleware.MessageMiddleware",
96 "django.middleware.locale.LocaleMiddleware",
97 "benefits.core.middleware.Healthcheck",
98 "benefits.core.middleware.HealthcheckUserAgents",
99 "django.middleware.common.CommonMiddleware",
100 "django.middleware.csrf.CsrfViewMiddleware",
101 "django.middleware.clickjacking.XFrameOptionsMiddleware",
102 "csp.middleware.CSPMiddleware",
103 "benefits.core.middleware.ChangedLanguageEvent",
104 "django.contrib.auth.middleware.AuthenticationMiddleware",
105 "django.contrib.messages.middleware.MessageMiddleware",
106]
108if DEBUG: 108 ↛ 109line 108 didn't jump to line 109 because the condition on line 108 was never true
109 MIDDLEWARE.append("benefits.core.middleware.DebugSession")
111HEALTHCHECK_USER_AGENTS = _filter_empty(os.environ.get("HEALTHCHECK_USER_AGENTS", "").split(","))
113CSRF_COOKIE_AGE = None
114CSRF_COOKIE_SAMESITE = "Strict"
115CSRF_COOKIE_HTTPONLY = True
116CSRF_TRUSTED_ORIGINS = _filter_empty(os.environ.get("DJANGO_TRUSTED_ORIGINS", "http://localhost,http://127.0.0.1").split(","))
118# With `Strict`, the user loses their Django session between leaving our app to
119# sign in with OAuth, and coming back into our app from the OAuth redirect.
120# This is because `Strict` disallows our cookie being sent from an external
121# domain and so the session cookie is lost.
122#
123# `Lax` allows the cookie to travel with the user and be sent back to us by the
124# OAuth server, as long as the request is "safe" i.e. GET
125SESSION_COOKIE_SAMESITE = "Lax"
126SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies"
127SESSION_EXPIRE_AT_BROWSER_CLOSE = True
128SESSION_COOKIE_NAME = "_benefitssessionid"
130if not DEBUG: 130 ↛ 135line 130 didn't jump to line 135 because the condition on line 130 was always true
131 CSRF_COOKIE_SECURE = True
132 CSRF_FAILURE_VIEW = "benefits.core.views.csrf_failure"
133 SESSION_COOKIE_SECURE = True
135SECURE_BROWSER_XSS_FILTER = True
137# required so that cross-origin pop-ups (like the enrollment overlay) have access to parent window context
138# https://github.com/cal-itp/benefits/pull/793
139SECURE_CROSS_ORIGIN_OPENER_POLICY = "same-origin-allow-popups"
141# the NGINX reverse proxy sits in front of the application in deployed environments
142# SSL terminates before getting to Django, and NGINX adds this header to indicate
143# if the original request was secure or not
144#
145# See https://docs.djangoproject.com/en/5.0/ref/settings/#secure-proxy-ssl-header
146if not DEBUG: 146 ↛ 149line 146 didn't jump to line 149 because the condition on line 146 was always true
147 SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
149ROOT_URLCONF = "benefits.urls"
151template_ctx_processors = [
152 "django.template.context_processors.request",
153 "django.contrib.auth.context_processors.auth",
154 "django.contrib.messages.context_processors.messages",
155 "benefits.core.context_processors.agency",
156 "benefits.core.context_processors.active_agencies",
157 "benefits.core.context_processors.analytics",
158 "benefits.core.context_processors.authentication",
159 "benefits.core.context_processors.enrollment",
160 "benefits.core.context_processors.origin",
161 "benefits.core.context_processors.routes",
162 "benefits.core.context_processors.feature_flags",
163]
165if DEBUG: 165 ↛ 166line 165 didn't jump to line 166 because the condition on line 165 was never true
166 template_ctx_processors.extend(
167 [
168 "django.template.context_processors.debug",
169 "benefits.core.context_processors.debug",
170 ]
171 )
173TEMPLATES = [
174 {
175 "BACKEND": "django.template.backends.django.DjangoTemplates",
176 "DIRS": [os.path.join(BASE_DIR, "benefits", "templates")],
177 "APP_DIRS": True,
178 "OPTIONS": {
179 "context_processors": template_ctx_processors,
180 },
181 },
182]
184WSGI_APPLICATION = "benefits.wsgi.application"
186STORAGE_DIR = os.environ.get("DJANGO_STORAGE_DIR", BASE_DIR)
187DATABASES = {
188 "default": {
189 "ENGINE": "django.db.backends.sqlite3",
190 "NAME": os.path.join(STORAGE_DIR, os.environ.get("DJANGO_DB_FILE", "django.db")),
191 }
192}
194# Password handling
196AUTH_PASSWORD_VALIDATORS = [
197 {
198 "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
199 },
200 {
201 "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
202 },
203 {
204 "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
205 },
206 {
207 "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
208 },
209]
210PASSWORD_RESET_TIMEOUT = 86400 # 24 hours, in seconds
213# Internationalization
215LANGUAGE_CODE = "en"
217LANGUAGE_COOKIE_HTTPONLY = True
218# `Lax` allows the cookie to travel with the user and be sent back to Benefits
219# during redirection e.g. through IdG/Login.gov or a Transit Processor portal
220# ensuring the app is displayed in the same language
221LANGUAGE_COOKIE_SAMESITE = "Lax"
222LANGUAGE_COOKIE_SECURE = True
224LANGUAGES = [("en", "English"), ("es", "Español")]
226LOCALE_PATHS = [os.path.join(BASE_DIR, "benefits", "locale")]
228USE_I18N = True
230# See https://docs.djangoproject.com/en/5.0/ref/settings/#std-setting-TIME_ZONE
231# > Note that this isn’t necessarily the time zone of the server.
232# > When USE_TZ is True, this is the default time zone that Django will use to display datetimes in templates
233# > and to interpret datetimes entered in forms.
234TIME_ZONE = "America/Los_Angeles"
235USE_TZ = True
237# https://docs.djangoproject.com/en/5.0/topics/i18n/formatting/#creating-custom-format-files
238FORMAT_MODULE_PATH = [
239 "benefits.locale",
240]
242# Static files (CSS, JavaScript, Images)
244STATIC_URL = "/static/"
245STATICFILES_DIRS = [os.path.join(BASE_DIR, "benefits", "static")]
246# use Manifest Static Files Storage by default
247STORAGES = {
248 "default": {
249 "BACKEND": "django.core.files.storage.FileSystemStorage",
250 },
251 "staticfiles": {
252 "BACKEND": os.environ.get(
253 "DJANGO_STATICFILES_STORAGE", "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"
254 )
255 },
256}
257STATIC_ROOT = os.path.join(BASE_DIR, "static")
259# User-uploaded files
261MEDIA_ROOT = os.path.join(STORAGE_DIR, "uploads/")
263MEDIA_URL = "/media/"
265# Logging configuration
266LOG_LEVEL = os.environ.get("DJANGO_LOG_LEVEL", "DEBUG" if DEBUG else "WARNING")
267LOGGING = {
268 "version": 1,
269 "disable_existing_loggers": False,
270 "formatters": {
271 "default": {
272 "format": "[{asctime}] {levelname} {name}:{lineno} {message}",
273 "datefmt": "%d/%b/%Y %H:%M:%S",
274 "style": "{",
275 },
276 },
277 "handlers": {
278 "console": {
279 "class": "logging.StreamHandler",
280 "formatter": "default",
281 },
282 },
283 "root": {
284 "handlers": ["console"],
285 "level": LOG_LEVEL,
286 },
287 "loggers": {
288 "django": {
289 "handlers": ["console"],
290 "propagate": False,
291 },
292 },
293}
295sentry.configure()
297# Analytics configuration
299ANALYTICS_KEY = os.environ.get("ANALYTICS_KEY")
301# reCAPTCHA configuration
303RECAPTCHA_API_URL = os.environ.get("DJANGO_RECAPTCHA_API_URL", "https://www.google.com/recaptcha/api.js")
304RECAPTCHA_SITE_KEY = os.environ.get("DJANGO_RECAPTCHA_SITE_KEY")
305RECAPTCHA_API_KEY_URL = f"{RECAPTCHA_API_URL}?render={RECAPTCHA_SITE_KEY}"
306RECAPTCHA_SECRET_KEY = os.environ.get("DJANGO_RECAPTCHA_SECRET_KEY")
307RECAPTCHA_VERIFY_URL = os.environ.get("DJANGO_RECAPTCHA_VERIFY_URL", "https://www.google.com/recaptcha/api/siteverify")
308RECAPTCHA_ENABLED = all((RECAPTCHA_API_URL, RECAPTCHA_SITE_KEY, RECAPTCHA_SECRET_KEY, RECAPTCHA_VERIFY_URL))
310# Content Security Policy
311# Configuration docs at https://django-csp.readthedocs.io/en/latest/configuration.html+
313CONTENT_SECURITY_POLICY = {
314 "DIRECTIVES": {
315 "base-uri": [NONE],
316 "connect-src": [
317 SELF,
318 "https://api.amplitude.com/",
319 "https://cdn.jsdelivr.net/npm/@switchio",
320 "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/",
321 "https://cdn.jsdelivr.net/npm/jquery",
322 ],
323 "default-src": [SELF],
324 "font-src": [SELF, "https://fonts.gstatic.com/"],
325 "frame-ancestors": [NONE],
326 "frame-src": ["*.littlepay.com"],
327 "img-src": [SELF, "data:", "*.googleusercontent.com"],
328 "object-src": [NONE],
329 "script-src": [
330 SELF,
331 "https://cdn.amplitude.com/libs/",
332 "https://cdn.jsdelivr.net/npm/@switchio",
333 "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/",
334 "https://cdn.jsdelivr.net/npm/jquery",
335 "*.littlepay.com",
336 NONCE, # https://django-csp.readthedocs.io/en/latest/nonce.html
337 ],
338 "style-src": [
339 SELF,
340 "https://fonts.googleapis.com/css",
341 "https://fonts.googleapis.com/css2",
342 "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/",
343 ],
344 }
345}
347# connect-src additions
348env_connect_src = _filter_empty(os.environ.get("DJANGO_CSP_CONNECT_SRC", "").split(","))
349if RECAPTCHA_ENABLED: 349 ↛ 350line 349 didn't jump to line 350 because the condition on line 349 was never true
350 env_connect_src.append("https://www.google.com/recaptcha/")
351CONTENT_SECURITY_POLICY["DIRECTIVES"]["connect-src"].extend(env_connect_src)
353# font-src additions
354env_font_src = _filter_empty(os.environ.get("DJANGO_CSP_FONT_SRC", "").split(","))
355CONTENT_SECURITY_POLICY["DIRECTIVES"]["font-src"].extend(env_font_src)
357# frame-src additions
358env_frame_src = _filter_empty(os.environ.get("DJANGO_CSP_FRAME_SRC", "").split(","))
359if RECAPTCHA_ENABLED: 359 ↛ 360line 359 didn't jump to line 360 because the condition on line 359 was never true
360 env_frame_src.append("https://www.google.com")
361CONTENT_SECURITY_POLICY["DIRECTIVES"]["frame-src"].extend(env_frame_src)
363# script-src additions
364env_script_src = _filter_empty(os.environ.get("DJANGO_CSP_SCRIPT_SRC", "").split(","))
365if RECAPTCHA_ENABLED: 365 ↛ 366line 365 didn't jump to line 366 because the condition on line 365 was never true
366 env_script_src.extend(["https://www.google.com/recaptcha/", "https://www.gstatic.com/recaptcha/releases/"])
367CONTENT_SECURITY_POLICY["DIRECTIVES"]["script-src"].extend(env_script_src)
369# style-src additions
370env_style_src = _filter_empty(os.environ.get("DJANGO_CSP_STYLE_SRC", "").split(","))
371CONTENT_SECURITY_POLICY["DIRECTIVES"]["style-src"].extend(env_style_src)
373# adjust report-uri when using Sentry
374if sentry.SENTRY_CSP_REPORT_URI: 374 ↛ 375line 374 didn't jump to line 375 because the condition on line 374 was never true
375 CONTENT_SECURITY_POLICY["DIRECTIVES"]["report-uri"] = sentry.SENTRY_CSP_REPORT_URI
378# Configuration for requests
379# https://requests.readthedocs.io/en/latest/user/advanced/#timeouts
381try:
382 REQUESTS_CONNECT_TIMEOUT = int(os.environ.get("REQUESTS_CONNECT_TIMEOUT"))
383except Exception:
384 REQUESTS_CONNECT_TIMEOUT = 3
386try:
387 REQUESTS_READ_TIMEOUT = int(os.environ.get("REQUESTS_READ_TIMEOUT"))
388except Exception:
389 REQUESTS_READ_TIMEOUT = 20
391REQUESTS_TIMEOUT = (REQUESTS_CONNECT_TIMEOUT, REQUESTS_READ_TIMEOUT)
393# Email
394# https://docs.djangoproject.com/en/5.2/ref/settings/#email-backend
395# https://github.com/retech-us/django-azure-communication-email
396AZURE_COMMUNICATION_CONNECTION_STRING = os.environ.get("AZURE_COMMUNICATION_CONNECTION_STRING")
398if AZURE_COMMUNICATION_CONNECTION_STRING: 398 ↛ 399line 398 didn't jump to line 399 because the condition on line 398 was never true
399 EMAIL_BACKEND = "django_azure_communication_email.EmailBackend"
400 EMAIL_USE_TLS = True
401else:
402 EMAIL_BACKEND = "django.core.mail.backends.filebased.EmailBackend"
403 EMAIL_FILE_PATH = os.path.join(STORAGE_DIR, ".sent_emails")
405# https://docs.djangoproject.com/en/5.2/ref/settings/#default-from-email
406DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "noreply@example.calitp.org")