Coverage for benefits/settings.py: 87%

139 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-08 19:50 +0000

1""" 

2Django settings for benefits project. 

3""" 

4 

5import os 

6 

7from csp.constants import NONCE, NONE, SELF 

8from django.conf import settings 

9 

10from benefits import sentry 

11 

12 

13def _filter_empty(ls): 

14 return [s for s in ls if s] 

15 

16 

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__))) 

19 

20# SECURITY WARNING: keep the secret key used in production secret! 

21SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "secret") 

22 

23# SECURITY WARNING: don't run with debug turned on in production! 

24DEBUG = os.environ.get("DJANGO_DEBUG", "false").lower() == "true" 

25 

26ALLOWED_HOSTS = _filter_empty(os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost").split(",")) 

27 

28 

29class RUNTIME_ENVS: 

30 LOCAL = "local" 

31 DEV = "dev" 

32 TEST = "test" 

33 PROD = "prod" 

34 

35 

36def RUNTIME_ENVIRONMENT(): 

37 """Helper calculates the current runtime environment from ALLOWED_HOSTS.""" 

38 

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 

49 

50 

51# Application definition 

52 

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] 

74 

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" 

92 

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] 

109 

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") 

112 

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 } 

122 

123 

124HEALTHCHECK_USER_AGENTS = _filter_empty(os.environ.get("HEALTHCHECK_USER_AGENTS", "").split(",")) 

125 

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(",")) 

130 

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" 

142 

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 

147 

148SECURE_BROWSER_XSS_FILTER = True 

149 

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" 

153 

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") 

161 

162ROOT_URLCONF = "benefits.urls" 

163 

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] 

179 

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 ) 

187 

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] 

198 

199WSGI_APPLICATION = "benefits.wsgi.application" 

200 

201STORAGE_DIR = os.environ.get("DJANGO_STORAGE_DIR", BASE_DIR) 

202 

203sslmode = os.environ.get("POSTGRES_SSLMODE", "verify-full") 

204sslrootcert = "/etc/ssl/certs/ca-certificates.crt" if sslmode == "verify-full" else None 

205 

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} 

227 

228 

229# Password handling 

230 

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 

246 

247 

248# Internationalization 

249 

250LANGUAGE_CODE = "en" 

251 

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 

258 

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] 

270 

271LANGUAGES = LANGUAGES_CORE + LANGUAGES_METRO 

272 

273LOCALE_PATHS = [os.path.join(BASE_DIR, "benefits", "locale")] 

274 

275USE_I18N = True 

276 

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 

283 

284# https://docs.djangoproject.com/en/stable/topics/i18n/formatting/#creating-custom-format-files 

285FORMAT_MODULE_PATH = [ 

286 "benefits.locale", 

287] 

288 

289# Static files (CSS, JavaScript, Images) 

290 

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") 

305 

306# User-uploaded files 

307 

308MEDIA_ROOT = os.path.join(STORAGE_DIR, "uploads/") 

309 

310MEDIA_URL = "/media/" 

311 

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 

315 

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} 

345 

346sentry.configure(RUNTIME_ENVIRONMENT()) 

347 

348# Analytics configuration 

349 

350ANALYTICS_KEY = os.environ.get("ANALYTICS_KEY") 

351 

352# reCAPTCHA configuration 

353 

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)) 

360 

361# Content Security Policy 

362# Configuration docs at https://django-csp.readthedocs.io/en/latest/configuration.html+ 

363 

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} 

401 

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) 

407 

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) 

411 

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) 

417 

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) 

423 

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) 

427 

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 

431 

432 

433# Configuration for requests 

434# https://requests.readthedocs.io/en/latest/user/advanced/#timeouts 

435 

436try: 

437 REQUESTS_CONNECT_TIMEOUT = int(os.environ.get("REQUESTS_CONNECT_TIMEOUT")) 

438except Exception: 

439 REQUESTS_CONNECT_TIMEOUT = 3 

440 

441try: 

442 REQUESTS_READ_TIMEOUT = int(os.environ.get("REQUESTS_READ_TIMEOUT")) 

443except Exception: 

444 REQUESTS_READ_TIMEOUT = 20 

445 

446REQUESTS_TIMEOUT = (REQUESTS_CONNECT_TIMEOUT, REQUESTS_READ_TIMEOUT) 

447 

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") 

452 

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") 

459 

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")