Coverage for benefits/settings.py: 90%
131 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-17 22:53 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-17 22:53 +0000
1"""
2Django settings for benefits project.
3"""
5import os
7from django.conf import settings
9from benefits import sentry
12def _filter_empty(ls):
13 return [s for s in ls if s]
16# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
17BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
19# SECURITY WARNING: keep the secret key used in production secret!
20SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "secret")
22# SECURITY WARNING: don't run with debug turned on in production!
23DEBUG = os.environ.get("DJANGO_DEBUG", "False").lower() == "true"
25ALLOWED_HOSTS = _filter_empty(os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost").split(","))
28class RUNTIME_ENVS:
29 LOCAL = "local"
30 DEV = "dev"
31 TEST = "test"
32 PROD = "prod"
35def RUNTIME_ENVIRONMENT():
36 """Helper calculates the current runtime environment from ALLOWED_HOSTS."""
38 # usage of django.conf.settings.ALLOWED_HOSTS here (rather than the module variable directly)
39 # is to ensure dynamic calculation, e.g. for unit tests and elsewhere this setting is needed
40 env = RUNTIME_ENVS.LOCAL
41 if "dev-benefits.calitp.org" in settings.ALLOWED_HOSTS:
42 env = RUNTIME_ENVS.DEV
43 elif "test-benefits.calitp.org" in settings.ALLOWED_HOSTS:
44 env = RUNTIME_ENVS.TEST
45 elif "benefits.calitp.org" in settings.ALLOWED_HOSTS:
46 env = RUNTIME_ENVS.PROD
47 return env
50# Application definition
52INSTALLED_APPS = [
53 "benefits.apps.BenefitsAdminConfig",
54 "django.contrib.auth",
55 "django.contrib.contenttypes",
56 "django.contrib.messages",
57 "django.contrib.sessions",
58 "django.contrib.staticfiles",
59 "adminsortable2",
60 "cdt_identity",
61 "django_google_sso",
62 "benefits.core",
63 "benefits.enrollment",
64 "benefits.enrollment_littlepay",
65 "benefits.enrollment_switchio",
66 "benefits.eligibility",
67 "benefits.oauth",
68 "benefits.in_person",
69]
71GOOGLE_SSO_CLIENT_ID = os.environ.get("GOOGLE_SSO_CLIENT_ID", "secret")
72GOOGLE_SSO_PROJECT_ID = os.environ.get("GOOGLE_SSO_PROJECT_ID", "benefits-admin")
73GOOGLE_SSO_CLIENT_SECRET = os.environ.get("GOOGLE_SSO_CLIENT_SECRET", "secret")
74GOOGLE_SSO_ALLOWABLE_DOMAINS = _filter_empty(os.environ.get("GOOGLE_SSO_ALLOWABLE_DOMAINS", "compiler.la").split(","))
75GOOGLE_SSO_STAFF_LIST = _filter_empty(os.environ.get("GOOGLE_SSO_STAFF_LIST", "").split(","))
76GOOGLE_SSO_SUPERUSER_LIST = _filter_empty(os.environ.get("GOOGLE_SSO_SUPERUSER_LIST", "").split(","))
77GOOGLE_SSO_LOGO_URL = "/static/img/icon/google_sso_logo.svg"
78GOOGLE_SSO_TEXT = "Log in with Google"
79GOOGLE_SSO_SAVE_ACCESS_TOKEN = True
80GOOGLE_SSO_PRE_LOGIN_CALLBACK = "benefits.core.admin.pre_login_user"
81GOOGLE_SSO_SCOPES = [
82 "openid",
83 "https://www.googleapis.com/auth/userinfo.email",
84 "https://www.googleapis.com/auth/userinfo.profile",
85]
86SSO_SHOW_FORM_ON_ADMIN_PAGE = os.environ.get("SSO_SHOW_FORM_ON_ADMIN_PAGE", "False").lower() == "true"
87STAFF_GROUP_NAME = "Cal-ITP"
89MIDDLEWARE = [
90 "django.middleware.security.SecurityMiddleware",
91 "django.contrib.sessions.middleware.SessionMiddleware",
92 "django.contrib.messages.middleware.MessageMiddleware",
93 "django.middleware.locale.LocaleMiddleware",
94 "benefits.core.middleware.Healthcheck",
95 "benefits.core.middleware.HealthcheckUserAgents",
96 "django.middleware.common.CommonMiddleware",
97 "django.middleware.csrf.CsrfViewMiddleware",
98 "django.middleware.clickjacking.XFrameOptionsMiddleware",
99 "csp.middleware.CSPMiddleware",
100 "benefits.core.middleware.ChangedLanguageEvent",
101 "django.contrib.auth.middleware.AuthenticationMiddleware",
102 "django.contrib.messages.middleware.MessageMiddleware",
103]
105if DEBUG: 105 ↛ 106line 105 didn't jump to line 106 because the condition on line 105 was never true
106 MIDDLEWARE.append("benefits.core.middleware.DebugSession")
108HEALTHCHECK_USER_AGENTS = _filter_empty(os.environ.get("HEALTHCHECK_USER_AGENTS", "").split(","))
110CSRF_COOKIE_AGE = None
111CSRF_COOKIE_SAMESITE = "Strict"
112CSRF_COOKIE_HTTPONLY = True
113CSRF_TRUSTED_ORIGINS = _filter_empty(os.environ.get("DJANGO_TRUSTED_ORIGINS", "http://localhost,http://127.0.0.1").split(","))
115# With `Strict`, the user loses their Django session between leaving our app to
116# sign in with OAuth, and coming back into our app from the OAuth redirect.
117# This is because `Strict` disallows our cookie being sent from an external
118# domain and so the session cookie is lost.
119#
120# `Lax` allows the cookie to travel with the user and be sent back to us by the
121# OAuth server, as long as the request is "safe" i.e. GET
122SESSION_COOKIE_SAMESITE = "Lax"
123SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies"
124SESSION_EXPIRE_AT_BROWSER_CLOSE = True
125SESSION_COOKIE_NAME = "_benefitssessionid"
127if not DEBUG: 127 ↛ 132line 127 didn't jump to line 132 because the condition on line 127 was always true
128 CSRF_COOKIE_SECURE = True
129 CSRF_FAILURE_VIEW = "benefits.core.views.csrf_failure"
130 SESSION_COOKIE_SECURE = True
132SECURE_BROWSER_XSS_FILTER = True
134# required so that cross-origin pop-ups (like the enrollment overlay) have access to parent window context
135# https://github.com/cal-itp/benefits/pull/793
136SECURE_CROSS_ORIGIN_OPENER_POLICY = "same-origin-allow-popups"
138# the NGINX reverse proxy sits in front of the application in deployed environments
139# SSL terminates before getting to Django, and NGINX adds this header to indicate
140# if the original request was secure or not
141#
142# See https://docs.djangoproject.com/en/5.0/ref/settings/#secure-proxy-ssl-header
143if not DEBUG: 143 ↛ 146line 143 didn't jump to line 146 because the condition on line 143 was always true
144 SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
146ROOT_URLCONF = "benefits.urls"
148template_ctx_processors = [
149 "django.template.context_processors.request",
150 "django.contrib.auth.context_processors.auth",
151 "django.contrib.messages.context_processors.messages",
152 "benefits.core.context_processors.agency",
153 "benefits.core.context_processors.active_agencies",
154 "benefits.core.context_processors.analytics",
155 "benefits.core.context_processors.authentication",
156 "benefits.core.context_processors.enrollment",
157 "benefits.core.context_processors.origin",
158 "benefits.core.context_processors.routes",
159 "benefits.core.context_processors.feature_flags",
160]
162if DEBUG: 162 ↛ 163line 162 didn't jump to line 163 because the condition on line 162 was never true
163 template_ctx_processors.extend(
164 [
165 "django.template.context_processors.debug",
166 "benefits.core.context_processors.debug",
167 ]
168 )
170TEMPLATES = [
171 {
172 "BACKEND": "django.template.backends.django.DjangoTemplates",
173 "DIRS": [os.path.join(BASE_DIR, "benefits", "templates")],
174 "APP_DIRS": True,
175 "OPTIONS": {
176 "context_processors": template_ctx_processors,
177 },
178 },
179]
181WSGI_APPLICATION = "benefits.wsgi.application"
183STORAGE_DIR = os.environ.get("DJANGO_STORAGE_DIR", BASE_DIR)
184DATABASES = {
185 "default": {
186 "ENGINE": "django.db.backends.sqlite3",
187 "NAME": os.path.join(STORAGE_DIR, os.environ.get("DJANGO_DB_FILE", "django.db")),
188 }
189}
191# Password validation
193AUTH_PASSWORD_VALIDATORS = [
194 {
195 "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
196 },
197 {
198 "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
199 },
200 {
201 "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
202 },
203 {
204 "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
205 },
206]
209# Internationalization
211LANGUAGE_CODE = "en"
213LANGUAGE_COOKIE_HTTPONLY = True
214# `Lax` allows the cookie to travel with the user and be sent back to Benefits
215# during redirection e.g. through IdG/Login.gov or a Transit Processor portal
216# ensuring the app is displayed in the same language
217LANGUAGE_COOKIE_SAMESITE = "Lax"
218LANGUAGE_COOKIE_SECURE = True
220LANGUAGES = [("en", "English"), ("es", "Español")]
222LOCALE_PATHS = [os.path.join(BASE_DIR, "benefits", "locale")]
224USE_I18N = True
226# See https://docs.djangoproject.com/en/5.0/ref/settings/#std-setting-TIME_ZONE
227# > Note that this isn’t necessarily the time zone of the server.
228# > When USE_TZ is True, this is the default time zone that Django will use to display datetimes in templates
229# > and to interpret datetimes entered in forms.
230TIME_ZONE = "America/Los_Angeles"
231USE_TZ = True
233# https://docs.djangoproject.com/en/5.0/topics/i18n/formatting/#creating-custom-format-files
234FORMAT_MODULE_PATH = [
235 "benefits.locale",
236]
238# Static files (CSS, JavaScript, Images)
240STATIC_URL = "/static/"
241STATICFILES_DIRS = [os.path.join(BASE_DIR, "benefits", "static")]
242# use Manifest Static Files Storage by default
243STORAGES = {
244 "default": {
245 "BACKEND": "django.core.files.storage.FileSystemStorage",
246 },
247 "staticfiles": {
248 "BACKEND": os.environ.get(
249 "DJANGO_STATICFILES_STORAGE", "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"
250 )
251 },
252}
253STATIC_ROOT = os.path.join(BASE_DIR, "static")
255# User-uploaded files
257MEDIA_ROOT = os.path.join(STORAGE_DIR, "uploads/")
259MEDIA_URL = "/media/"
261# Logging configuration
262LOG_LEVEL = os.environ.get("DJANGO_LOG_LEVEL", "DEBUG" if DEBUG else "WARNING")
263LOGGING = {
264 "version": 1,
265 "disable_existing_loggers": False,
266 "formatters": {
267 "default": {
268 "format": "[{asctime}] {levelname} {name}:{lineno} {message}",
269 "datefmt": "%d/%b/%Y %H:%M:%S",
270 "style": "{",
271 },
272 },
273 "handlers": {
274 "console": {
275 "class": "logging.StreamHandler",
276 "formatter": "default",
277 },
278 },
279 "root": {
280 "handlers": ["console"],
281 "level": LOG_LEVEL,
282 },
283 "loggers": {
284 "django": {
285 "handlers": ["console"],
286 "propagate": False,
287 },
288 },
289}
291sentry.configure()
293# Analytics configuration
295ANALYTICS_KEY = os.environ.get("ANALYTICS_KEY")
297# reCAPTCHA configuration
299RECAPTCHA_API_URL = os.environ.get("DJANGO_RECAPTCHA_API_URL", "https://www.google.com/recaptcha/api.js")
300RECAPTCHA_SITE_KEY = os.environ.get("DJANGO_RECAPTCHA_SITE_KEY")
301RECAPTCHA_API_KEY_URL = f"{RECAPTCHA_API_URL}?render={RECAPTCHA_SITE_KEY}"
302RECAPTCHA_SECRET_KEY = os.environ.get("DJANGO_RECAPTCHA_SECRET_KEY")
303RECAPTCHA_VERIFY_URL = os.environ.get("DJANGO_RECAPTCHA_VERIFY_URL", "https://www.google.com/recaptcha/api/siteverify")
304RECAPTCHA_ENABLED = all((RECAPTCHA_API_URL, RECAPTCHA_SITE_KEY, RECAPTCHA_SECRET_KEY, RECAPTCHA_VERIFY_URL))
306# Content Security Policy
307# Configuration docs at https://django-csp.readthedocs.io/en/latest/configuration.html
309# In particular, note that the inner single-quotes are required!
310# https://django-csp.readthedocs.io/en/latest/configuration.html#policy-settings
312CSP_BASE_URI = ["'none'"]
314CSP_DEFAULT_SRC = ["'self'"]
316CSP_CONNECT_SRC = ["'self'", "https://api.amplitude.com/"]
317env_connect_src = _filter_empty(os.environ.get("DJANGO_CSP_CONNECT_SRC", "").split(","))
318if RECAPTCHA_ENABLED: 318 ↛ 319line 318 didn't jump to line 319 because the condition on line 318 was never true
319 env_connect_src.append("https://www.google.com/recaptcha/")
320CSP_CONNECT_SRC.extend(env_connect_src)
322CSP_FONT_SRC = ["'self'", "https://fonts.gstatic.com/"]
323env_font_src = _filter_empty(os.environ.get("DJANGO_CSP_FONT_SRC", "").split(","))
324CSP_FONT_SRC.extend(env_font_src)
326CSP_FRAME_ANCESTORS = ["'none'"]
328CSP_FRAME_SRC = ["*.littlepay.com"]
329env_frame_src = _filter_empty(os.environ.get("DJANGO_CSP_FRAME_SRC", "").split(","))
330if RECAPTCHA_ENABLED: 330 ↛ 331line 330 didn't jump to line 331 because the condition on line 330 was never true
331 env_frame_src.append("https://www.google.com")
332if len(env_frame_src) > 0: 332 ↛ 333line 332 didn't jump to line 333 because the condition on line 332 was never true
333 CSP_FRAME_SRC.extend(env_frame_src)
335CSP_IMG_SRC = [
336 "'self'",
337 "data:",
338 "*.googleusercontent.com",
339]
341# Configuring strict Content Security Policy
342# https://django-csp.readthedocs.io/en/latest/nonce.html
343CSP_INCLUDE_NONCE_IN = ["script-src"]
345CSP_OBJECT_SRC = ["'none'"]
347if sentry.SENTRY_CSP_REPORT_URI: 347 ↛ 348line 347 didn't jump to line 348 because the condition on line 347 was never true
348 CSP_REPORT_URI = [sentry.SENTRY_CSP_REPORT_URI]
350CSP_SCRIPT_SRC = [
351 "'self'",
352 "https://cdn.amplitude.com/libs/",
353 "https://cdn.jsdelivr.net/",
354 "*.littlepay.com",
355 "https://code.jquery.com/jquery-3.6.0.min.js",
356]
357env_script_src = _filter_empty(os.environ.get("DJANGO_CSP_SCRIPT_SRC", "").split(","))
358CSP_SCRIPT_SRC.extend(env_script_src)
359if RECAPTCHA_ENABLED: 359 ↛ 360line 359 didn't jump to line 360 because the condition on line 359 was never true
360 CSP_SCRIPT_SRC.extend(["https://www.google.com/recaptcha/", "https://www.gstatic.com/recaptcha/releases/"])
362CSP_STYLE_SRC = [
363 "'self'",
364 "'unsafe-inline'",
365 "https://fonts.googleapis.com/css",
366 "https://fonts.googleapis.com/css2",
367 "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/",
368]
369env_style_src = _filter_empty(os.environ.get("DJANGO_CSP_STYLE_SRC", "").split(","))
370CSP_STYLE_SRC.extend(env_style_src)
372# Configuration for requests
373# https://requests.readthedocs.io/en/latest/user/advanced/#timeouts
375try:
376 REQUESTS_CONNECT_TIMEOUT = int(os.environ.get("REQUESTS_CONNECT_TIMEOUT"))
377except Exception:
378 REQUESTS_CONNECT_TIMEOUT = 3
380try:
381 REQUESTS_READ_TIMEOUT = int(os.environ.get("REQUESTS_READ_TIMEOUT"))
382except Exception:
383 REQUESTS_READ_TIMEOUT = 20
385REQUESTS_TIMEOUT = (REQUESTS_CONNECT_TIMEOUT, REQUESTS_READ_TIMEOUT)