Coverage for benefits/settings.py: 90%

131 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-17 22:53 +0000

1""" 

2Django settings for benefits project. 

3""" 

4 

5import os 

6 

7from django.conf import settings 

8 

9from benefits import sentry 

10 

11 

12def _filter_empty(ls): 

13 return [s for s in ls if s] 

14 

15 

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

18 

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

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

21 

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

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

24 

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

26 

27 

28class RUNTIME_ENVS: 

29 LOCAL = "local" 

30 DEV = "dev" 

31 TEST = "test" 

32 PROD = "prod" 

33 

34 

35def RUNTIME_ENVIRONMENT(): 

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

37 

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 

48 

49 

50# Application definition 

51 

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] 

70 

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" 

88 

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] 

104 

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

107 

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

109 

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

114 

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" 

126 

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 

131 

132SECURE_BROWSER_XSS_FILTER = True 

133 

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" 

137 

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

145 

146ROOT_URLCONF = "benefits.urls" 

147 

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] 

161 

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 ) 

169 

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] 

180 

181WSGI_APPLICATION = "benefits.wsgi.application" 

182 

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} 

190 

191# Password validation 

192 

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] 

207 

208 

209# Internationalization 

210 

211LANGUAGE_CODE = "en" 

212 

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 

219 

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

221 

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

223 

224USE_I18N = True 

225 

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 

232 

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

234FORMAT_MODULE_PATH = [ 

235 "benefits.locale", 

236] 

237 

238# Static files (CSS, JavaScript, Images) 

239 

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

254 

255# User-uploaded files 

256 

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

258 

259MEDIA_URL = "/media/" 

260 

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} 

290 

291sentry.configure() 

292 

293# Analytics configuration 

294 

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

296 

297# reCAPTCHA configuration 

298 

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

305 

306# Content Security Policy 

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

308 

309# In particular, note that the inner single-quotes are required! 

310# https://django-csp.readthedocs.io/en/latest/configuration.html#policy-settings 

311 

312CSP_BASE_URI = ["'none'"] 

313 

314CSP_DEFAULT_SRC = ["'self'"] 

315 

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) 

321 

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) 

325 

326CSP_FRAME_ANCESTORS = ["'none'"] 

327 

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) 

334 

335CSP_IMG_SRC = [ 

336 "'self'", 

337 "data:", 

338 "*.googleusercontent.com", 

339] 

340 

341# Configuring strict Content Security Policy 

342# https://django-csp.readthedocs.io/en/latest/nonce.html 

343CSP_INCLUDE_NONCE_IN = ["script-src"] 

344 

345CSP_OBJECT_SRC = ["'none'"] 

346 

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] 

349 

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

361 

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) 

371 

372# Configuration for requests 

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

374 

375try: 

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

377except Exception: 

378 REQUESTS_CONNECT_TIMEOUT = 3 

379 

380try: 

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

382except Exception: 

383 REQUESTS_READ_TIMEOUT = 20 

384 

385REQUESTS_TIMEOUT = (REQUESTS_CONNECT_TIMEOUT, REQUESTS_READ_TIMEOUT)