Coverage for sm/settings.py: 76%

126 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-24 19:06 +0000

1import os 

2import dj_database_url 

3from pathlib import Path 

4from sys import platform, argv 

5import django.contrib.messages as messages 

6from django.core.exceptions import ImproperlyConfigured 

7from decouple import config, Csv 

8 

9# Build paths inside the project like this: BASE_DIR / 'subdir'. 

10BASE_DIR = Path(__file__).resolve().parent.parent 

11 

12# Environment-based configuration 

13DEBUG = config("DEBUG", default=False, cast=bool) 

14 

15TEST_RUNNER = "sm.runner.SmTestRunner" 

16_cfg_secret = config("SECRET_KEY", default="") 

17if not _cfg_secret: 

18 if DEBUG: 

19 _cfg_secret = "django-insecure-default-key-for-dev" 

20 else: 

21 raise ImproperlyConfigured("SECRET_KEY must be set when DEBUG is False.") 

22SECRET_KEY = _cfg_secret 

23ALLOWED_HOSTS = config("ALLOWED_HOSTS", default="localhost,127.0.0.1", cast=Csv()) 

24EMAIL_BACKEND = config( 

25 "EMAIL_BACKEND", default="django.core.mail.backends.console.EmailBackend" 

26) 

27 

28THEME_CONTACT_EMAIL = config("THEME_CONTACT_EMAIL", default="oliver@linux-kernel.at") 

29THEME_GITHUB_URL = config("THEME_GITHUB_URL", default="https://github.com/ofalk/sm") 

30 

31# Send an email to group members + superusers when a server's status changes. 

32SERVER_STATUS_NOTIFY = config("SERVER_STATUS_NOTIFY", default=False, cast=bool) 

33 

34# Version information 

35APP_VERSION = config("APP_VERSION", default="unknown") 

36APP_MODIFICATION_DATE = config("APP_MODIFICATION_DATE", default="unknown") 

37 

38if APP_VERSION == "unknown" or APP_MODIFICATION_DATE == "unknown": 

39 try: 

40 import subprocess 

41 

42 if APP_VERSION == "unknown": 

43 APP_VERSION = ( 

44 subprocess.check_output( 

45 ["git", "rev-parse", "--short", "HEAD"], stderr=subprocess.DEVNULL 

46 ) 

47 .decode("utf-8") 

48 .strip() 

49 ) 

50 if APP_MODIFICATION_DATE == "unknown": 

51 APP_MODIFICATION_DATE = ( 

52 subprocess.check_output( 

53 ["git", "log", "-1", "--format=%cd"], stderr=subprocess.DEVNULL 

54 ) 

55 .decode("utf-8") 

56 .strip() 

57 ) 

58 except Exception: 

59 pass 

60 

61DISABLE_SOCIAL_AUTH = config("DISABLE_SOCIAL_AUTH", default=False, cast=bool) 

62 

63# Analytics settings 

64ANALYTICS_ENABLED = config("ANALYTICS_ENABLED", default=False, cast=bool) 

65ANALYTICS_ID = config("ANALYTICS_ID", default=None) 

66ANALYTICS_BASE_URL = config("ANALYTICS_BASE_URL", default="https://api.swetrix.com") 

67 

68# Application definition 

69 

70INSTALLED_APPS = [ 

71 "whitenoise.runserver_nostatic", 

72 "django.contrib.admin", 

73 "django.contrib.auth", 

74 "django.contrib.contenttypes", 

75 "django.contrib.sessions", 

76 "django.contrib.messages", 

77 "django.contrib.staticfiles", 

78 "django.contrib.sites", 

79 "django.contrib.admindocs", 

80 "rest_framework", 

81 "django_bootstrap5", 

82 "django_countries", 

83 "taggit", 

84 "simple_history", 

85 "drf_spectacular", 

86 # Allauth 

87 "allauth", 

88 "allauth.account", 

89 "allauth.socialaccount", 

90 "allauth.mfa", 

91 "social_django", 

92] 

93 

94if DEBUG: 

95 INSTALLED_APPS += ["debug_toolbar"] 

96 

97if not DISABLE_SOCIAL_AUTH: 

98 INSTALLED_APPS += [ 

99 "allauth.socialaccount.providers.google", 

100 "allauth.socialaccount.providers.openid_connect", 

101 ] 

102 SOCIALACCOUNT_ENABLED = True 

103else: 

104 SOCIALACCOUNT_ENABLED = False 

105 

106INSTALLED_APPS += [ 

107 # Project Apps 

108 "cluster", 

109 "operatingsystem", 

110 "clusterpackage", 

111 "patchtime", 

112 "location", 

113 "servermodel", 

114 "server", 

115 "status", 

116 "domain", 

117 "clustersoftware", 

118 "clusterpackagetype", 

119 "vendor", 

120 "sm.apps.SmConfig", 

121] 

122 

123MIDDLEWARE = [ 

124 "django.middleware.security.SecurityMiddleware", 

125 "whitenoise.middleware.WhiteNoiseMiddleware", 

126 "django.contrib.sessions.middleware.SessionMiddleware", 

127 "django.middleware.locale.LocaleMiddleware", 

128 "django.middleware.common.CommonMiddleware", 

129 "django.middleware.csrf.CsrfViewMiddleware", 

130 "django.contrib.auth.middleware.AuthenticationMiddleware", 

131 "django.contrib.messages.middleware.MessageMiddleware", 

132 "django.middleware.clickjacking.XFrameOptionsMiddleware", 

133 "htmlmin.middleware.HtmlMinifyMiddleware", 

134 "htmlmin.middleware.MarkRequestMiddleware", 

135 "django.contrib.sites.middleware.CurrentSiteMiddleware", 

136 "simple_history.middleware.HistoryRequestMiddleware", 

137 # Allauth middleware 

138 "allauth.account.middleware.AccountMiddleware", 

139] 

140 

141if DEBUG: 

142 # Insert DebugToolbarMiddleware after SessionMiddleware but before CommonMiddleware 

143 try: 

144 session_idx = MIDDLEWARE.index( 

145 "django.contrib.sessions.middleware.SessionMiddleware" 

146 ) 

147 MIDDLEWARE.insert( 

148 session_idx + 1, "debug_toolbar.middleware.DebugToolbarMiddleware" 

149 ) 

150 except ValueError: 

151 MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware") 

152 

153ROOT_URLCONF = "sm.urls" 

154 

155TEMPLATES = [ 

156 { 

157 "BACKEND": "django.template.backends.django.DjangoTemplates", 

158 "DIRS": [BASE_DIR / "sm" / "templates"], 

159 "OPTIONS": { 

160 "context_processors": [ 

161 "django.template.context_processors.debug", 

162 "django.template.context_processors.request", 

163 "django.contrib.auth.context_processors.auth", 

164 "django.contrib.messages.context_processors.messages", 

165 "django.template.context_processors.i18n", 

166 "django.template.context_processors.media", 

167 "django.template.context_processors.static", 

168 "django.template.context_processors.tz", 

169 "sm.context_processors.theme_settings", 

170 ], 

171 "loaders": [ 

172 "django.template.loaders.filesystem.Loader", 

173 "sm.template.loaders.app_directories_enhanced.Loader", 

174 "django.template.loaders.app_directories.Loader", 

175 ], 

176 }, 

177 }, 

178] 

179 

180WSGI_APPLICATION = "sm.wsgi.application" 

181 

182# Database 

183# https://docs.djangoproject.com/en/stable/ref/settings/#databases 

184 

185DATABASE_URL = config("DATABASE_URL", default=None) 

186 

187if DATABASE_URL: 

188 DATABASES = { 

189 "default": dj_database_url.config(default=DATABASE_URL, conn_max_age=600) 

190 } 

191elif "test" in argv: 

192 # LiveServerTestCase passes the shared in-memory connection to the live 

193 # server thread, which is the configuration Django explicitly recommends 

194 # for threaded live-server tests (see LiveServerTestCase source). WAL + 

195 # busy timeout (set in sm/signals.py) further harden against locks. 

196 DATABASES = { 

197 "default": { 

198 "ENGINE": "django.db.backends.sqlite3", 

199 "NAME": ":memory:", 

200 "OPTIONS": {"timeout": 30}, 

201 } 

202 } 

203elif platform == "darwin": 

204 DATABASES = { 

205 "default": { 

206 "ENGINE": "django.db.backends.sqlite3", 

207 "NAME": BASE_DIR / "db.sqlite3", 

208 } 

209 } 

210else: 

211 # Use config_local or environment variables for MySQL if needed 

212 DATABASES = { 

213 "default": { 

214 "ENGINE": "django.db.backends.sqlite3", 

215 "NAME": BASE_DIR / "db.sqlite3", 

216 } 

217 } 

218 

219if "test" in argv: 

220 # Persistent connections (CONN_MAX_AGE) make each live-server request 

221 # thread keep an idle PostgreSQL session open, which then prevents 

222 # dropping the test database ("database is being accessed by other 

223 # users"). Force connection-per-request while testing. 

224 for db_config in DATABASES.values(): 

225 db_config["CONN_MAX_AGE"] = 0 

226 

227# Password validation 

228# https://docs.djangoproject.com/en/stable/ref/settings/#auth-password-validators 

229 

230AUTH_PASSWORD_VALIDATORS = [ 

231 { 

232 "NAME": ( 

233 "django.contrib.auth.password_validation." 

234 "UserAttributeSimilarityValidator" 

235 ) 

236 }, 

237 {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, 

238 {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, 

239 {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, 

240] 

241 

242# Internationalization 

243# https://docs.djangoproject.com/en/stable/topics/i18n/ 

244 

245LANGUAGE_CODE = "en-us" 

246TIME_ZONE = "UTC" 

247USE_I18N = True 

248USE_TZ = True 

249 

250# Path where application-level translations live (po/mo files). 

251LOCALE_PATHS = [ 

252 BASE_DIR / "sm" / "locale", 

253] 

254 

255# Static files (CSS, JavaScript, Images) 

256# https://docs.djangoproject.com/en/stable/howto/static-files/ 

257 

258STATIC_URL = "/static/" 

259STATIC_ROOT = BASE_DIR / "static" 

260STATICFILES_DIRS = [ 

261 BASE_DIR / "sm" / "static", 

262] 

263 

264STORAGES = { 

265 "default": { 

266 "BACKEND": "django.core.files.storage.FileSystemStorage", 

267 }, 

268 "staticfiles": { 

269 "BACKEND": "whitenoise.storage.CompressedStaticFilesStorage", 

270 }, 

271} 

272 

273# Default primary key field type 

274# https://docs.djangoproject.com/en/stable/ref/settings/#default-auto-field 

275 

276DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 

277 

278# Extra settings 

279 

280SITE_ID = 1 

281 

282AUTHENTICATION_BACKENDS = [ 

283 # Needed to login by username in Django admin, regardless of `allauth` 

284 "django.contrib.auth.backends.ModelBackend", 

285 # `allauth` specific authentication methods, such as login by e-mail 

286 "allauth.account.auth_backends.AuthenticationBackend", 

287] 

288 

289# Allauth settings 

290LOGIN_REDIRECT_URL = "/" 

291ACCOUNT_LOGIN_METHODS = {"username", "email"} 

292ACCOUNT_SIGNUP_FIELDS = ["email*", "username*", "password1*", "password2*"] 

293ACCOUNT_EMAIL_VERIFICATION = "optional" 

294ACCOUNT_EMAIL_CONFIRMATION_ANONYMOUS_REDIRECT_URL = "/" 

295ACCOUNT_EMAIL_CONFIRMATION_AUTHENTICATED_REDIRECT_URL = "/" 

296ACCOUNT_LOGIN_REDIRECT_URL = "/" 

297ACCOUNT_LOGOUT_REDIRECT_URL = "/" 

298 

299# Social Auth (Legacy/Transition) 

300SOCIAL_AUTH_STRATEGY = "social_django.strategy.DjangoStrategy" 

301SOCIAL_AUTH_STORAGE = "social_django.models.DjangoStorage" 

302 

303# Disable MFA features that might cause issues if allauth.mfa is not fully configured 

304MFA_PASSKEY_LOGIN_ENABLED = False 

305MFA_SUPPORTED_TYPES = ["totp", "recovery_codes"] 

306 

307if not DISABLE_SOCIAL_AUTH: 

308 SOCIALACCOUNT_AUTO_SIGNUP = True 

309 SOCIALACCOUNT_ADAPTER = "sm.adapter.MySocialAccountAdapter" 

310 

311 SOCIALACCOUNT_PROVIDERS = {} 

312 

313 # Google Auth 

314 google_id = config("GOOGLE_CLIENT_ID", default=None) 

315 google_secret = config("GOOGLE_SECRET", default=None) 

316 if google_id and google_secret: 

317 SOCIALACCOUNT_PROVIDERS["google"] = { 

318 "APPS": [ 

319 { 

320 "client_id": google_id, 

321 "secret": google_secret, 

322 "settings": { 

323 "scope": ["profile", "email"], 

324 "auth_params": {"access_type": "online"}, 

325 }, 

326 } 

327 ] 

328 } 

329 

330 # Generic OIDC 

331 oidc_id = config("OIDC_CLIENT_ID", default=None) 

332 oidc_secret = config("OIDC_SECRET", default=None) 

333 oidc_url = config("OIDC_URL", default=None) 

334 oidc_name = config("OIDC_NAME", default="Localghost SSO") 

335 

336 if oidc_id and oidc_secret and oidc_url: 

337 SOCIALACCOUNT_PROVIDERS["openid_connect"] = { 

338 "APPS": [ 

339 { 

340 "provider_id": "oidc", 

341 "name": oidc_name, 

342 "client_id": oidc_id, 

343 "secret": oidc_secret, 

344 "settings": {"server_url": oidc_url}, 

345 } 

346 ] 

347 } 

348 

349INTERNAL_IPS = [ 

350 "127.0.0.1", 

351] 

352 

353MESSAGE_TAGS = {messages.ERROR: "danger"} 

354 

355MESSAGE_STORAGE = "django.contrib.messages.storage.fallback.FallbackStorage" 

356 

357TAGGIT_CASE_INSENSITIVE = True 

358 

359# REST Framework Settings 

360REST_FRAMEWORK = { 

361 "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", 

362 "DEFAULT_AUTHENTICATION_CLASSES": [ 

363 "sm.api.authentication.ApiKeyAuthentication", 

364 "rest_framework.authentication.SessionAuthentication", 

365 ], 

366 "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", 

367 "PAGE_SIZE": 25, 

368 "DEFAULT_FILTER_BACKENDS": [ 

369 "rest_framework.filters.SearchFilter", 

370 "rest_framework.filters.OrderingFilter", 

371 ], 

372 "DEFAULT_THROTTLE_RATES": { 

373 "user": "1000/hour", 

374 "anon": "100/hour", 

375 }, 

376 "DEFAULT_THROTTLE_CLASSES": [ 

377 "rest_framework.throttling.UserRateThrottle", 

378 "rest_framework.throttling.AnonRateThrottle", 

379 ], 

380} 

381 

382SPECTACULAR_SETTINGS = { 

383 "TITLE": "ServerManager API", 

384 "DESCRIPTION": "API for managing servers, clusters, and infrastructure.", 

385 "VERSION": "1.0.0", 

386 "SERVE_INCLUDE_SCHEMA": False, 

387} 

388 

389# Bootstrap 5 settings 

390BOOTSTRAP5 = { 

391 "error_css_class": "bootstrap5-error", 

392 "required_css_class": "bootstrap5-required", 

393 "javascript_in_head": True, 

394 "success_css_class": "bootstrap5-bound", 

395} 

396 

397HTML_MINIFY = True 

398 

399# Security hardening (only applied when not in DEBUG so local HTTP dev works). 

400# Skipped while running the test suite, which always runs with DEBUG=False and 

401# uses plain-HTTP requests that SECURE_SSL_REDIRECT would redirect away. 

402if not DEBUG and "test" not in argv: 

403 SECURE_SSL_REDIRECT = config("SECURE_SSL_REDIRECT", default=True, cast=bool) 

404 SESSION_COOKIE_SECURE = config("SESSION_COOKIE_SECURE", default=True, cast=bool) 

405 CSRF_COOKIE_SECURE = config("CSRF_COOKIE_SECURE", default=True, cast=bool) 

406 SECURE_HSTS_SECONDS = config("SECURE_HSTS_SECONDS", default=31536000, cast=int) 

407 SECURE_HSTS_INCLUDE_SUBDOMAINS = True 

408 SECURE_HSTS_PRELOAD = True 

409 SECURE_CONTENT_TYPE_NOSNIFF = True 

410 SECURE_REFERRER_POLICY = "same-origin" 

411 X_FRAME_OPTIONS = "DENY" 

412 

413if not DISABLE_SOCIAL_AUTH and os.path.isfile(BASE_DIR / "config_local.py"): 

414 from config_local import * # noqa 

415 

416# Redefine to ensure no social_core backends survive in quick test mode 

417if DISABLE_SOCIAL_AUTH: 

418 AUTHENTICATION_BACKENDS = [ 

419 "django.contrib.auth.backends.ModelBackend", 

420 "allauth.account.auth_backends.AuthenticationBackend", 

421 ]