Coverage for benefits/settings.py: 90%
131 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-13 23:09 +0000
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-13 23:09 +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.eligibility",
65 "benefits.oauth",
66 "benefits.in_person",
67]
69GOOGLE_SSO_CLIENT_ID = os.environ.get("GOOGLE_SSO_CLIENT_ID", "secret")
70GOOGLE_SSO_PROJECT_ID = os.environ.get("GOOGLE_SSO_PROJECT_ID", "benefits-admin")
71GOOGLE_SSO_CLIENT_SECRET = os.environ.get("GOOGLE_SSO_CLIENT_SECRET", "secret")
72GOOGLE_SSO_ALLOWABLE_DOMAINS = _filter_empty(os.environ.get("GOOGLE_SSO_ALLOWABLE_DOMAINS", "compiler.la").split(","))
73GOOGLE_SSO_STAFF_LIST = _filter_empty(os.environ.get("GOOGLE_SSO_STAFF_LIST", "").split(","))
74GOOGLE_SSO_SUPERUSER_LIST = _filter_empty(os.environ.get("GOOGLE_SSO_SUPERUSER_LIST", "").split(","))
75GOOGLE_SSO_LOGO_URL = "/static/img/icon/google_sso_logo.svg"
76GOOGLE_SSO_TEXT = "Log in with Google"
77GOOGLE_SSO_SAVE_ACCESS_TOKEN = True
78GOOGLE_SSO_PRE_LOGIN_CALLBACK = "benefits.core.admin.pre_login_user"
79GOOGLE_SSO_SCOPES = [
80 "openid",
81 "https://www.googleapis.com/auth/userinfo.email",
82 "https://www.googleapis.com/auth/userinfo.profile",
83]
84SSO_SHOW_FORM_ON_ADMIN_PAGE = os.environ.get("SSO_SHOW_FORM_ON_ADMIN_PAGE", "False").lower() == "true"
85STAFF_GROUP_NAME = "Cal-ITP"
87MIDDLEWARE = [
88 "django.middleware.security.SecurityMiddleware",
89 "django.contrib.sessions.middleware.SessionMiddleware",
90 "django.contrib.messages.middleware.MessageMiddleware",
91 "django.middleware.locale.LocaleMiddleware",
92 "benefits.core.middleware.Healthcheck",
93 "benefits.core.middleware.HealthcheckUserAgents",
94 "django.middleware.common.CommonMiddleware",
95 "django.middleware.csrf.CsrfViewMiddleware",
96 "django.middleware.clickjacking.XFrameOptionsMiddleware",
97 "csp.middleware.CSPMiddleware",
98 "benefits.core.middleware.ChangedLanguageEvent",
99 "django.contrib.auth.middleware.AuthenticationMiddleware",
100 "django.contrib.messages.middleware.MessageMiddleware",
101]
103if DEBUG: 103 ↛ 104line 103 didn't jump to line 104 because the condition on line 103 was never true
104 MIDDLEWARE.append("benefits.core.middleware.DebugSession")
106HEALTHCHECK_USER_AGENTS = _filter_empty(os.environ.get("HEALTHCHECK_USER_AGENTS", "").split(","))
108CSRF_COOKIE_AGE = None
109CSRF_COOKIE_SAMESITE = "Strict"
110CSRF_COOKIE_HTTPONLY = True
111CSRF_TRUSTED_ORIGINS = _filter_empty(os.environ.get("DJANGO_TRUSTED_ORIGINS", "http://localhost,http://127.0.0.1").split(","))
113# With `Strict`, the user loses their Django session between leaving our app to
114# sign in with OAuth, and coming back into our app from the OAuth redirect.
115# This is because `Strict` disallows our cookie being sent from an external
116# domain and so the session cookie is lost.
117#
118# `Lax` allows the cookie to travel with the user and be sent back to us by the
119# OAuth server, as long as the request is "safe" i.e. GET
120SESSION_COOKIE_SAMESITE = "Lax"
121SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies"
122SESSION_EXPIRE_AT_BROWSER_CLOSE = True
123SESSION_COOKIE_NAME = "_benefitssessionid"
125if not DEBUG: 125 ↛ 130line 125 didn't jump to line 130 because the condition on line 125 was always true
126 CSRF_COOKIE_SECURE = True
127 CSRF_FAILURE_VIEW = "benefits.core.views.csrf_failure"
128 SESSION_COOKIE_SECURE = True
130SECURE_BROWSER_XSS_FILTER = True
132# required so that cross-origin pop-ups (like the enrollment overlay) have access to parent window context
133# https://github.com/cal-itp/benefits/pull/793
134SECURE_CROSS_ORIGIN_OPENER_POLICY = "same-origin-allow-popups"
136# the NGINX reverse proxy sits in front of the application in deployed environments
137# SSL terminates before getting to Django, and NGINX adds this header to indicate
138# if the original request was secure or not
139#
140# See https://docs.djangoproject.com/en/5.0/ref/settings/#secure-proxy-ssl-header
141if not DEBUG: 141 ↛ 144line 141 didn't jump to line 144 because the condition on line 141 was always true
142 SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
144ROOT_URLCONF = "benefits.urls"
146template_ctx_processors = [
147 "django.template.context_processors.request",
148 "django.contrib.auth.context_processors.auth",
149 "django.contrib.messages.context_processors.messages",
150 "benefits.core.context_processors.agency",
151 "benefits.core.context_processors.active_agencies",
152 "benefits.core.context_processors.analytics",
153 "benefits.core.context_processors.authentication",
154 "benefits.core.context_processors.enrollment",
155 "benefits.core.context_processors.origin",
156 "benefits.core.context_processors.routes",
157]
159if DEBUG: 159 ↛ 160line 159 didn't jump to line 160 because the condition on line 159 was never true
160 template_ctx_processors.extend(
161 [
162 "django.template.context_processors.debug",
163 "benefits.core.context_processors.debug",
164 ]
165 )
167TEMPLATES = [
168 {
169 "BACKEND": "django.template.backends.django.DjangoTemplates",
170 "DIRS": [os.path.join(BASE_DIR, "benefits", "templates")],
171 "APP_DIRS": True,
172 "OPTIONS": {
173 "context_processors": template_ctx_processors,
174 },
175 },
176]
178WSGI_APPLICATION = "benefits.wsgi.application"
180STORAGE_DIR = os.environ.get("DJANGO_STORAGE_DIR", BASE_DIR)
181DATABASES = {
182 "default": {
183 "ENGINE": "django.db.backends.sqlite3",
184 "NAME": os.path.join(STORAGE_DIR, os.environ.get("DJANGO_DB_FILE", "django.db")),
185 }
186}
188# Password validation
190AUTH_PASSWORD_VALIDATORS = [
191 {
192 "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
193 },
194 {
195 "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
196 },
197 {
198 "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
199 },
200 {
201 "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
202 },
203]
206# Internationalization
208LANGUAGE_CODE = "en"
210LANGUAGE_COOKIE_HTTPONLY = True
211LANGUAGE_COOKIE_SAMESITE = "Strict"
212LANGUAGE_COOKIE_SECURE = True
214LANGUAGES = [("en", "English"), ("es", "Español")]
216LOCALE_PATHS = [os.path.join(BASE_DIR, "benefits", "locale")]
218USE_I18N = True
220# See https://docs.djangoproject.com/en/5.0/ref/settings/#std-setting-TIME_ZONE
221# > Note that this isn’t necessarily the time zone of the server.
222# > When USE_TZ is True, this is the default time zone that Django will use to display datetimes in templates
223# > and to interpret datetimes entered in forms.
224TIME_ZONE = "America/Los_Angeles"
225USE_TZ = True
227# https://docs.djangoproject.com/en/5.0/topics/i18n/formatting/#creating-custom-format-files
228FORMAT_MODULE_PATH = [
229 "benefits.locale",
230]
232# Static files (CSS, JavaScript, Images)
234STATIC_URL = "/static/"
235STATICFILES_DIRS = [os.path.join(BASE_DIR, "benefits", "static")]
236# use Manifest Static Files Storage by default
237STORAGES = {
238 "default": {
239 "BACKEND": "django.core.files.storage.FileSystemStorage",
240 },
241 "staticfiles": {
242 "BACKEND": os.environ.get(
243 "DJANGO_STATICFILES_STORAGE", "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"
244 )
245 },
246}
247STATIC_ROOT = os.path.join(BASE_DIR, "static")
249# User-uploaded files
251MEDIA_ROOT = os.path.join(STORAGE_DIR, "uploads/")
253MEDIA_URL = "/media/"
255# Logging configuration
256LOG_LEVEL = os.environ.get("DJANGO_LOG_LEVEL", "DEBUG" if DEBUG else "WARNING")
257LOGGING = {
258 "version": 1,
259 "disable_existing_loggers": False,
260 "formatters": {
261 "default": {
262 "format": "[{asctime}] {levelname} {name}:{lineno} {message}",
263 "datefmt": "%d/%b/%Y %H:%M:%S",
264 "style": "{",
265 },
266 },
267 "handlers": {
268 "console": {
269 "class": "logging.StreamHandler",
270 "formatter": "default",
271 },
272 },
273 "root": {
274 "handlers": ["console"],
275 "level": LOG_LEVEL,
276 },
277 "loggers": {
278 "django": {
279 "handlers": ["console"],
280 "propagate": False,
281 },
282 },
283}
285sentry.configure()
287# Analytics configuration
289ANALYTICS_KEY = os.environ.get("ANALYTICS_KEY")
291# reCAPTCHA configuration
293RECAPTCHA_API_URL = os.environ.get("DJANGO_RECAPTCHA_API_URL", "https://www.google.com/recaptcha/api.js")
294RECAPTCHA_SITE_KEY = os.environ.get("DJANGO_RECAPTCHA_SITE_KEY")
295RECAPTCHA_API_KEY_URL = f"{RECAPTCHA_API_URL}?render={RECAPTCHA_SITE_KEY}"
296RECAPTCHA_SECRET_KEY = os.environ.get("DJANGO_RECAPTCHA_SECRET_KEY")
297RECAPTCHA_VERIFY_URL = os.environ.get("DJANGO_RECAPTCHA_VERIFY_URL", "https://www.google.com/recaptcha/api/siteverify")
298RECAPTCHA_ENABLED = all((RECAPTCHA_API_URL, RECAPTCHA_SITE_KEY, RECAPTCHA_SECRET_KEY, RECAPTCHA_VERIFY_URL))
300# Content Security Policy
301# Configuration docs at https://django-csp.readthedocs.io/en/latest/configuration.html
303# In particular, note that the inner single-quotes are required!
304# https://django-csp.readthedocs.io/en/latest/configuration.html#policy-settings
306CSP_BASE_URI = ["'none'"]
308CSP_DEFAULT_SRC = ["'self'"]
310CSP_CONNECT_SRC = ["'self'", "https://api.amplitude.com/"]
311env_connect_src = _filter_empty(os.environ.get("DJANGO_CSP_CONNECT_SRC", "").split(","))
312if RECAPTCHA_ENABLED: 312 ↛ 313line 312 didn't jump to line 313 because the condition on line 312 was never true
313 env_connect_src.append("https://www.google.com/recaptcha/")
314CSP_CONNECT_SRC.extend(env_connect_src)
316CSP_FONT_SRC = ["'self'", "https://fonts.gstatic.com/"]
317env_font_src = _filter_empty(os.environ.get("DJANGO_CSP_FONT_SRC", "").split(","))
318CSP_FONT_SRC.extend(env_font_src)
320CSP_FRAME_ANCESTORS = ["'none'"]
322CSP_FRAME_SRC = ["*.littlepay.com"]
323env_frame_src = _filter_empty(os.environ.get("DJANGO_CSP_FRAME_SRC", "").split(","))
324if RECAPTCHA_ENABLED: 324 ↛ 325line 324 didn't jump to line 325 because the condition on line 324 was never true
325 env_frame_src.append("https://www.google.com")
326if len(env_frame_src) > 0: 326 ↛ 327line 326 didn't jump to line 327 because the condition on line 326 was never true
327 CSP_FRAME_SRC.extend(env_frame_src)
329CSP_IMG_SRC = [
330 "'self'",
331 "data:",
332 "*.googleusercontent.com",
333]
335# Configuring strict Content Security Policy
336# https://django-csp.readthedocs.io/en/latest/nonce.html
337CSP_INCLUDE_NONCE_IN = ["script-src"]
339CSP_OBJECT_SRC = ["'none'"]
341if sentry.SENTRY_CSP_REPORT_URI: 341 ↛ 342line 341 didn't jump to line 342 because the condition on line 341 was never true
342 CSP_REPORT_URI = [sentry.SENTRY_CSP_REPORT_URI]
344CSP_SCRIPT_SRC = [
345 "'self'",
346 "https://cdn.amplitude.com/libs/",
347 "https://cdn.jsdelivr.net/",
348 "*.littlepay.com",
349 "https://code.jquery.com/jquery-3.6.0.min.js",
350]
351env_script_src = _filter_empty(os.environ.get("DJANGO_CSP_SCRIPT_SRC", "").split(","))
352CSP_SCRIPT_SRC.extend(env_script_src)
353if RECAPTCHA_ENABLED: 353 ↛ 354line 353 didn't jump to line 354 because the condition on line 353 was never true
354 CSP_SCRIPT_SRC.extend(["https://www.google.com/recaptcha/", "https://www.gstatic.com/recaptcha/releases/"])
356CSP_STYLE_SRC = [
357 "'self'",
358 "'unsafe-inline'",
359 "https://fonts.googleapis.com/css",
360 "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/",
361]
362env_style_src = _filter_empty(os.environ.get("DJANGO_CSP_STYLE_SRC", "").split(","))
363CSP_STYLE_SRC.extend(env_style_src)
365# Configuration for requests
366# https://requests.readthedocs.io/en/latest/user/advanced/#timeouts
368try:
369 REQUESTS_CONNECT_TIMEOUT = int(os.environ.get("REQUESTS_CONNECT_TIMEOUT"))
370except Exception:
371 REQUESTS_CONNECT_TIMEOUT = 3
373try:
374 REQUESTS_READ_TIMEOUT = int(os.environ.get("REQUESTS_READ_TIMEOUT"))
375except Exception:
376 REQUESTS_READ_TIMEOUT = 20
378REQUESTS_TIMEOUT = (REQUESTS_CONNECT_TIMEOUT, REQUESTS_READ_TIMEOUT)