Coverage for benefits / settings.py: 89%

129 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-22 19:08 +0000

1""" 

2Django settings for benefits project. 

3""" 

4 

5import os 

6 

7from django.conf import settings 

8 

9from csp.constants import NONCE, NONE, SELF 

10 

11from benefits import sentry 

12 

13 

14def _filter_empty(ls): 

15 return [s for s in ls if s] 

16 

17 

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

20 

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

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

23 

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

25DEBUG = os.environ.get("DJANGO_DEBUG", "False").lower() == "true" 

26 

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

28 

29 

30class RUNTIME_ENVS: 

31 LOCAL = "local" 

32 DEV = "dev" 

33 TEST = "test" 

34 PROD = "prod" 

35 

36 

37def RUNTIME_ENVIRONMENT(): 

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

39 

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 

50 

51 

52# Application definition 

53 

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] 

73 

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" 

91 

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] 

107 

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

110 

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

112 

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

117 

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" 

129 

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 

134 

135SECURE_BROWSER_XSS_FILTER = True 

136 

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" 

140 

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

148 

149ROOT_URLCONF = "benefits.urls" 

150 

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] 

164 

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 ) 

172 

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] 

183 

184WSGI_APPLICATION = "benefits.wsgi.application" 

185 

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} 

193 

194# Password handling 

195 

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 

211 

212 

213# Internationalization 

214 

215LANGUAGE_CODE = "en" 

216 

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 

223 

224LANGUAGES = [("en", "English"), ("es", "Español")] 

225 

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

227 

228USE_I18N = True 

229 

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 

236 

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

238FORMAT_MODULE_PATH = [ 

239 "benefits.locale", 

240] 

241 

242# Static files (CSS, JavaScript, Images) 

243 

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

258 

259# User-uploaded files 

260 

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

262 

263MEDIA_URL = "/media/" 

264 

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} 

294 

295sentry.configure() 

296 

297# Analytics configuration 

298 

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

300 

301# reCAPTCHA configuration 

302 

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

309 

310# Content Security Policy 

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

312 

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} 

346 

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) 

352 

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) 

356 

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) 

362 

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) 

368 

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) 

372 

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 

376 

377 

378# Configuration for requests 

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

380 

381try: 

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

383except Exception: 

384 REQUESTS_CONNECT_TIMEOUT = 3 

385 

386try: 

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

388except Exception: 

389 REQUESTS_READ_TIMEOUT = 20 

390 

391REQUESTS_TIMEOUT = (REQUESTS_CONNECT_TIMEOUT, REQUESTS_READ_TIMEOUT) 

392 

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

397 

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

404 

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