Coverage for sm/settings.py: 76%
126 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-24 19:06 +0000
« 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
9# Build paths inside the project like this: BASE_DIR / 'subdir'.
10BASE_DIR = Path(__file__).resolve().parent.parent
12# Environment-based configuration
13DEBUG = config("DEBUG", default=False, cast=bool)
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)
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")
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)
34# Version information
35APP_VERSION = config("APP_VERSION", default="unknown")
36APP_MODIFICATION_DATE = config("APP_MODIFICATION_DATE", default="unknown")
38if APP_VERSION == "unknown" or APP_MODIFICATION_DATE == "unknown":
39 try:
40 import subprocess
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
61DISABLE_SOCIAL_AUTH = config("DISABLE_SOCIAL_AUTH", default=False, cast=bool)
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")
68# Application definition
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]
94if DEBUG:
95 INSTALLED_APPS += ["debug_toolbar"]
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
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]
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]
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")
153ROOT_URLCONF = "sm.urls"
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]
180WSGI_APPLICATION = "sm.wsgi.application"
182# Database
183# https://docs.djangoproject.com/en/stable/ref/settings/#databases
185DATABASE_URL = config("DATABASE_URL", default=None)
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 }
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
227# Password validation
228# https://docs.djangoproject.com/en/stable/ref/settings/#auth-password-validators
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]
242# Internationalization
243# https://docs.djangoproject.com/en/stable/topics/i18n/
245LANGUAGE_CODE = "en-us"
246TIME_ZONE = "UTC"
247USE_I18N = True
248USE_TZ = True
250# Path where application-level translations live (po/mo files).
251LOCALE_PATHS = [
252 BASE_DIR / "sm" / "locale",
253]
255# Static files (CSS, JavaScript, Images)
256# https://docs.djangoproject.com/en/stable/howto/static-files/
258STATIC_URL = "/static/"
259STATIC_ROOT = BASE_DIR / "static"
260STATICFILES_DIRS = [
261 BASE_DIR / "sm" / "static",
262]
264STORAGES = {
265 "default": {
266 "BACKEND": "django.core.files.storage.FileSystemStorage",
267 },
268 "staticfiles": {
269 "BACKEND": "whitenoise.storage.CompressedStaticFilesStorage",
270 },
271}
273# Default primary key field type
274# https://docs.djangoproject.com/en/stable/ref/settings/#default-auto-field
276DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
278# Extra settings
280SITE_ID = 1
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]
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 = "/"
299# Social Auth (Legacy/Transition)
300SOCIAL_AUTH_STRATEGY = "social_django.strategy.DjangoStrategy"
301SOCIAL_AUTH_STORAGE = "social_django.models.DjangoStorage"
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"]
307if not DISABLE_SOCIAL_AUTH:
308 SOCIALACCOUNT_AUTO_SIGNUP = True
309 SOCIALACCOUNT_ADAPTER = "sm.adapter.MySocialAccountAdapter"
311 SOCIALACCOUNT_PROVIDERS = {}
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 }
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")
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 }
349INTERNAL_IPS = [
350 "127.0.0.1",
351]
353MESSAGE_TAGS = {messages.ERROR: "danger"}
355MESSAGE_STORAGE = "django.contrib.messages.storage.fallback.FallbackStorage"
357TAGGIT_CASE_INSENSITIVE = True
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}
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}
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}
397HTML_MINIFY = True
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"
413if not DISABLE_SOCIAL_AUTH and os.path.isfile(BASE_DIR / "config_local.py"):
414 from config_local import * # noqa
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 ]