From 13791f2262b49e8d2072b67527824c23e3e91c68 Mon Sep 17 00:00:00 2001 From: Niklas Mohrin Date: Mon, 30 Jun 2025 19:40:38 +0200 Subject: [PATCH 01/22] wip wip wip move side effects out of settings move driver code into own file and port more settings working resolver Use modules instead of classes format lint --- evap/__main__.py | 8 + evap/new_settings/__init__.py | 0 evap/new_settings/debug_toolbar.py | 38 +++ evap/new_settings/default.py | 434 +++++++++++++++++++++++++++++ evap/new_settings/dev.py | 19 ++ evap/new_settings/lazy.py | 114 ++++++++ evap/new_settings/open_id.py | 25 ++ evap/new_settings/test.py | 49 ++++ 8 files changed, 687 insertions(+) create mode 100644 evap/new_settings/__init__.py create mode 100644 evap/new_settings/debug_toolbar.py create mode 100644 evap/new_settings/default.py create mode 100644 evap/new_settings/dev.py create mode 100644 evap/new_settings/lazy.py create mode 100644 evap/new_settings/open_id.py create mode 100644 evap/new_settings/test.py diff --git a/evap/__main__.py b/evap/__main__.py index b09b03d085..3331006007 100755 --- a/evap/__main__.py +++ b/evap/__main__.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 +import logging import os import sys @@ -11,6 +12,13 @@ def main(): assert not settings.configured os.environ.setdefault("DJANGO_SETTINGS_MODULE", "evap.settings") settings.DATADIR.mkdir(exist_ok=True) + + if settings.TESTING: + from typeguard import install_import_hook # pylint: disable=import-outside-toplevel + + install_import_hook(("evap", "tools")) + logging.disable() + execute_from_command_line(sys.argv) diff --git a/evap/new_settings/__init__.py b/evap/new_settings/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/evap/new_settings/debug_toolbar.py b/evap/new_settings/debug_toolbar.py new file mode 100644 index 0000000000..ac2f69314d --- /dev/null +++ b/evap/new_settings/debug_toolbar.py @@ -0,0 +1,38 @@ +from evap.new_settings.lazy import derived + +# Very helpful but eats a lot of performance on sql-heavy pages. +# Works only with DEBUG = True and Django's development server (so no apache). +ENABLE_DEBUG_TOOLBAR = False + + +@derived(final={"DEBUG", "ENABLE_DEBUG_TOOLBAR", "TESTING"}) +def REALLY_ENABLE_DEBUG_TOOLBAR(prev, final): + return final.ENABLE_DEBUG_TOOLBAR and final.DEBUG and not final.TESTING + + +@derived(prev={"INSTALLED_APPS"}, final={"REALLY_ENABLE_DEBUG_TOOLBAR"}) +def INSTALLED_APPS(prev, final): + if final.REALLY_ENABLE_DEBUG_TOOLBAR: + return prev.INSTALLED_APPS + ["debug_toolbar"] + return prev.INSTALLED_APPS + + +@derived(prev={"MIDDLEWARE"}, final={"REALLY_ENABLE_DEBUG_TOOLBAR"}) +def MIDDLEWARE(prev, final): + if final.REALLY_ENABLE_DEBUG_TOOLBAR: + return ["debug_toolbar.middleware.DebugToolbarMiddleware"] + prev.MIDDLEWARE + return prev.MIDDLEWARE + + +def show_toolbar(request): + return True + + +@derived(prev={"DEBUG_TOOLBAR_CONFIG"}, final={"REALLY_ENABLE_DEBUG_TOOLBAR"}) +def DEBUG_TOOLBAR_CONFIG(prev, final): + if final.REALLY_ENABLE_DEBUG_TOOLBAR: + return { + "SHOW_TOOLBAR_CALLBACK": "evap.new_settings.debug_toolbar.show_toolbar", + "JQUERY_URL": "", + } + return prev.DEBUG_TOOLBAR_CONFIG diff --git a/evap/new_settings/default.py b/evap/new_settings/default.py new file mode 100644 index 0000000000..6c0fd1c04c --- /dev/null +++ b/evap/new_settings/default.py @@ -0,0 +1,434 @@ +# ruff: noqa: E731, N803 + +from datetime import timedelta +from fractions import Fraction +from pathlib import Path +from typing import Any + +from django.contrib.staticfiles.storage import ManifestStaticFilesStorage + +import evap +from evap.new_settings.lazy import derived, required +from evap.tools import MonthAndDay + + +class ManifestStaticFilesStorageWithJsReplacement(ManifestStaticFilesStorage): + support_js_module_import_aggregation = True + + +MODULE = Path(evap.__file__).parent +CWD = Path.cwd().resolve() + + +@derived(final={"CWD"}) +def DATADIR(prev, final): + return final.CWD / "data" + + +### Debugging + +DEBUG = required() + +### EvaP logic + +LOGIN_KEY_VALIDITY = 210 # days, so roughly 7 months + +VOTER_COUNT_NEEDED_FOR_PUBLISHING_RATING_RESULTS = 2 +VOTER_PERCENTAGE_NEEDED_FOR_PUBLISHING_AVERAGE_GRADE = 0.2 +SMALL_COURSE_SIZE = 5 # up to which number of participants the evaluation gets additional warnings about anonymity +PARTICIPATION_DELETION_AFTER_INACTIVE_TIME = timedelta(days=18 * 30) + +# a warning is shown next to results where less than RESULTS_WARNING_COUNT answers were given +# or the number of answers is less than RESULTS_WARNING_PERCENTAGE times the median number of answers (for this question in this evaluation) +RESULTS_WARNING_COUNT = 4 +RESULTS_WARNING_PERCENTAGE = 0.5 + +## percentages for calculating an evaluation's total average grade +# grade questions are weighted this much for each contributor's average grade +CONTRIBUTOR_GRADE_QUESTIONS_WEIGHT = 4 +# non-grade questions are weighted this much for each contributor's average grade +CONTRIBUTOR_NON_GRADE_RATING_QUESTIONS_WEIGHT = 6 +# the average contribution grade is weighted this much for the evaluation's average grade +CONTRIBUTIONS_WEIGHT = 1 +# the average grade of all general grade questions is weighted this much for the evaluation's average grade +GENERAL_GRADE_QUESTIONS_WEIGHT = 1 +# the average grade of all general non-grade questions is weighted this much for the evaluation's average grade +GENERAL_NON_GRADE_QUESTIONS_WEIGHT = 1 + +# number of reward points a student should have for a semester after evaluating the given fraction of evaluations. +REWARD_POINTS = [ + (1 / 3, 1), + (2 / 3, 2), + (3 / 3, 3), +] + +# days before end date to send reminder +REMIND_X_DAYS_AHEAD_OF_END_DATE = [2, 0] + +# days of the week on which managers are reminded to handle urgent text answer reviews +# where Monday is 0 and Sunday is 6 +TEXTANSWER_REVIEW_REMINDER_WEEKDAYS = [3] + +# Email addresses that are reminded about uploading grade documents +GRADE_REMINDER_EMAIL_RECIPIENTS: list[str] = [] +# Dates on which grade upload reminder emails are sent. +GRADE_REMINDER_EMAIL_DATES = [ + MonthAndDay(month=3, day=15), + MonthAndDay(month=9, day=15), +] + +# email domains for the internal users of the hosting institution used to +# figure out who is an internal user +INSTITUTION_EMAIL_DOMAINS: list[str] = ["institution.example.com", "student.institution.example.com"] + +# List of tuples defining email domains that should be replaced on saving UserProfiles. +# Emails ending on the first value will have this part replaced by the second value. +# e.g.: [("institution.example.com", "institution.com")] +INSTITUTION_EMAIL_REPLACEMENTS: list[tuple[str, str]] = [] + +# the importer accepts only these strings in the 'graded' column +IMPORTER_GRADED_YES = ["yes", "ja", "graded", "benotet"] +IMPORTER_GRADED_NO = ["no", "nein", "ungraded", "unbenotet"] + +# the importer will warn if any participant has more enrollments than this number +IMPORTER_MAX_ENROLLMENTS = 7 + +# Cutoff value passed to difflib.get_close_matches() to find typos in course names. Lower values are slower. +IMPORTER_COURSE_NAME_SIMILARITY_WARNING_THRESHOLD = 0.9 + +# the default descriptions for grade documents +DEFAULT_FINAL_GRADES_DESCRIPTION_EN = "Final grades" +DEFAULT_MIDTERM_GRADES_DESCRIPTION_EN = "Midterm grades" +DEFAULT_FINAL_GRADES_DESCRIPTION_DE = "Endnoten" +DEFAULT_MIDTERM_GRADES_DESCRIPTION_DE = "Zwischennoten" + +# Specify an offset that will be added to the evaluation end date (e.g. 3: If the end date is 01.01., the evaluation will end at 02.01. 03:00.). +EVALUATION_END_OFFSET_HOURS = 3 + +# Amount of hours in which participant will be warned +EVALUATION_END_WARNING_PERIOD = 5 + +# Questionnaires automatically added to exam evaluations +EXAM_QUESTIONNAIRE_IDS: list[int] = [] + +# Emails of users that shouldn't be imported as responsibles during JSON import +NON_RESPONSIBLE_USERS: set[str] = set() + +# Study programs that shouldn't be imported during JSON import +IGNORE_PROGRAMS: set[str] = set() + +### Installation specific settings + +# People who get emails on errors. +ADMINS: list[tuple[str, str]] = [ + # ('Your Name', 'your_email@example.com'), +] + +# The page URL that is used in email templates. +PAGE_URL = required() + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": "evap", + "USER": "evap", + "PASSWORD": "evap", + "HOST": "localhost", + "CONN_MAX_AGE": 600, + } +} + +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.redis.RedisCache", + "LOCATION": "redis://127.0.0.1:6379/0", + }, + "results": { + "BACKEND": "django.core.cache.backends.redis.RedisCache", + "LOCATION": "redis://127.0.0.1:6379/1", + "TIMEOUT": None, # is always invalidated manually + }, + "sessions": { + "BACKEND": "django.core.cache.backends.redis.RedisCache", + "LOCATION": "redis://127.0.0.1:6379/2", + }, +} + +STORAGES = { + "default": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + }, + "staticfiles": { + "BACKEND": "evap.new_settings.common.ManifestStaticFilesStorageWithJsReplacement", + }, +} + +CONTACT_EMAIL = required() +ALLOW_ANONYMOUS_FEEDBACK_MESSAGES = True +LEGAL_NOTICE_TEXT = required() + +# Config for mail system +DEFAULT_FROM_EMAIL = required() + + +@derived(final={"DEFAULT_FROM_EMAIL"}) +def REPLY_TO_EMAIL(prev, final): + return final.DEFAULT_FROM_EMAIL + + +SEND_ALL_EMAILS_TO_ADMINS_IN_BCC = required() + + +@derived(prev={"EMAIL_BACKEND"}, final={"DEBUG"}) +def EMAIL_BACKEND(prev, final): + if final.DEBUG: + return "django.core.mail.backends.console.EmailBackend" + return prev.EMAIL_BACKEND + + +@derived(final={"DATADIR"}) +def LOGGING(prev, final): + return { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "default": { + "format": "[%(asctime)s] %(levelname)s: %(message)s", + }, + }, + "handlers": { + "file": { + "level": "DEBUG", + "class": "logging.handlers.RotatingFileHandler", + "filename": final.DATADIR / "evap.log", + "maxBytes": 1024 * 1024 * 10, + "backupCount": 5, + "formatter": "default", + }, + "mail_admins": { + "level": "ERROR", + "class": "django.utils.log.AdminEmailHandler", + }, + "console": { + "class": "logging.StreamHandler", + "formatter": "default", + }, + }, + "loggers": { + "django": { + "handlers": ["console", "file", "mail_admins"], + "level": "INFO", + "propagate": True, + }, + "evap": { + "handlers": ["console", "file", "mail_admins"], + "level": "DEBUG", + "propagate": True, + }, + "mozilla_django_oidc": { + "handlers": ["console", "file", "mail_admins"], + "level": "DEBUG", + "propagate": True, + }, + }, + } + + +### Application definition + +AUTH_USER_MODEL = "evaluation.UserProfile" + +INSTALLED_APPS = [ + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "django_extensions", + "evap.evaluation", + "evap.staff", + "evap.results", + "evap.student", + "evap.contributor", + "evap.rewards", + "evap.grades", + "django.forms", + "mozilla_django_oidc", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + # LocaleMiddleware should be here according to https://docs.djangoproject.com/en/2.2/topics/i18n/translation/#how-django-discovers-language-preference + "django.middleware.locale.LocaleMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "mozilla_django_oidc.middleware.SessionRefresh", + "evap.middleware.RequireLoginMiddleware", + "evap.middleware.user_language_middleware", + "evap.staff.staff_mode.staff_mode_middleware", + "evap.evaluation.middleware.LoggingRequestMiddleware", +] + +_TEMPLATE_OPTIONS = { + "context_processors": [ + "django.contrib.auth.context_processors.auth", + "django.template.context_processors.debug", + "django.template.context_processors.i18n", + "django.template.context_processors.static", + "django.template.context_processors.request", + "django.contrib.messages.context_processors.messages", + "evap.context_processors.slogan", + "evap.context_processors.debug", + "evap.context_processors.notebook_form", + "evap.context_processors.allow_anonymous_feedback_messages", + ], + "builtins": ["django.templatetags.i18n"], +} + +TEMPLATES: Any = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "APP_DIRS": True, + "OPTIONS": _TEMPLATE_OPTIONS, + "NAME": "MainEngine", + }, + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "APP_DIRS": True, + "OPTIONS": {**_TEMPLATE_OPTIONS, "debug": False}, + "NAME": "CachedEngine", # used for bulk-filling caches + }, +] + +# This allows to redefine form widget templates used by Django when generating forms. +# The templates are located in evaluation/templates/django/forms/widgets and add the "form-control" class for correct bootstrap styling. +FORM_RENDERER = "django.forms.renderers.TemplatesSetting" + +AUTHENTICATION_BACKENDS = [ + "evap.evaluation.auth.RequestAuthUserBackend", + "evap.evaluation.auth.OpenIDAuthenticationBackend", + "evap.evaluation.auth.EmailAuthenticationBackend", +] + +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" + +ROOT_URLCONF = "evap.urls" + +WSGI_APPLICATION = "evap.wsgi.application" + +LOGIN_REDIRECT_URL = "/" +LOGOUT_REDIRECT_URL = "/" + +LOGIN_URL = "/" + +SESSION_ENGINE = "django.contrib.sessions.backends.cache" +SESSION_CACHE_ALIAS = "sessions" + +SESSION_SAVE_EVERY_REQUEST = True +SESSION_COOKIE_AGE = 60 * 60 * 24 * 365 # one year + +STAFF_MODE_TIMEOUT = 3 * 60 * 60 # three hours +STAFF_MODE_INFO_TIMEOUT = 3 * 60 * 60 # three hours + +# Disable the check for number of post parameters to enable, e.g., large numbers of participants in forms +# see https://docs.djangoproject.com/en/5.0/ref/settings/#data-upload-max-number-fields +DATA_UPLOAD_MAX_NUMBER_FIELDS = None + +### Internationalization + +LANGUAGE_CODE = "en" + +TIME_ZONE = "Europe/Berlin" + +USE_I18N = True + +USE_TZ = False + + +@derived(final={"MODULE"}) +def LOCALE_PATHS(prev, final): + return [final.MODULE / "locale"] + + +FORMAT_MODULE_PATH = ["evap.locale"] + +LANGUAGES = [ + ("en", "English"), + ("de", "Deutsch"), +] + +### Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/2.0/howto/static-files/ + +STATIC_URL = "/static/" + + +# Additional locations of static files +@derived(final={"MODULE"}) +def STATICFILES_DIRS(prev, final): + return [final.MODULE / "static"] + + +# Absolute path to the directory static files should be collected to. +@derived(final={"DATADIR"}) +def STATIC_ROOT(prev, final): + return final.DATADIR / "static_collected" + + +### User-uploaded files + + +# Absolute filesystem path to the directory that will hold user-uploaded files. +@derived(final={"DATADIR"}) +def MEDIA_ROOT(prev, final): + return final.DATADIR / "upload" + + +### Evaluation progress rewards + +# (required_voter_ratio between 0 and 1, reward_text) +GLOBAL_EVALUATION_PROGRESS_REWARDS: list[tuple[Fraction, str]] = [] +GLOBAL_EVALUATION_PROGRESS_EXCLUDED_COURSE_TYPE_IDS: list[int] = [] +GLOBAL_EVALUATION_PROGRESS_EXCLUDED_EVALUATION_IDS: list[int] = [] +GLOBAL_EVALUATION_PROGRESS_INFO_TEXT: dict[str, str] = {"de": "", "en": ""} + +### Slogans +SLOGANS_DE = [ + "Evaluierungen verlässlich ausführen und präsentieren", + "Entscheidungsgrundlage zur Verbesserung akademischer Programme", + "Ein voll atemberaubendes Projekt", + "Evaluierungs-Vereinfachung aus Potsdam", + "Elegante Verwaltung automatisierter Performancemessungen", + "Effektive Vermeidung von anstrengendem Papierkram", + "Einfach Verantwortlichen Abstimmungsergebnisse präsentieren", + "Ein Vorzeigeprojekt auf Python-Basis", + "Erleichtert Verfolgung aufgetretener Probleme", + "Entwickelt von arbeitsamen Personen", +] +SLOGANS_EN = [ + "Extremely valuable automated processing", + "Exploring various answers professionally", + "Encourages values and perfection", + "Enables virtuously adressed petitions", + "Evades very annoying paperwork", + "Engineered voluntarily and passionately", + "Elegant valiantly administered platform", + "Efficient voting and processing", + "Everyone values awesome products", + "Enhances vibrant academic programs", +] + + +### Allowed chosen first names / display names +def CHARACTER_ALLOWED_IN_NAME(character): # pylint: disable=invalid-name + return any( + ( + ord(character) in range(32, 127), # printable ASCII / Basic Latin characters + ord(character) in range(160, 256), # printable Latin-1 Supplement characters + ord(character) in range(256, 384), # Latin Extended-A + ) + ) diff --git a/evap/new_settings/dev.py b/evap/new_settings/dev.py new file mode 100644 index 0000000000..1df76e10a4 --- /dev/null +++ b/evap/new_settings/dev.py @@ -0,0 +1,19 @@ +from evap.new_settings.lazy import derived + +PAGE_URL = "localhost:8000" +ACTIVATE_OPEN_ID_LOGIN = False + + +@derived(prev={"INSTALLED_APPS"}, final={"DEBUG"}) +def INSTALLED_APPS(prev, final): + if final.DEBUG: + return prev.INSTALLED_APPS + ["evap.development"] + return prev.INSTALLED_APPS + + +CONTACT_EMAIL = "webmaster@localhost" +DEFAULT_FROM_EMAIL = "webmaster@localhost" +REPLY_TO_EMAIL = DEFAULT_FROM_EMAIL +SEND_ALL_EMAILS_TO_ADMINS_IN_BCC = False + +LEGAL_NOTICE_TEXT = "Objection! (this is a default setting that the administrators should change, please contact them)" diff --git a/evap/new_settings/lazy.py b/evap/new_settings/lazy.py new file mode 100644 index 0000000000..aec916e733 --- /dev/null +++ b/evap/new_settings/lazy.py @@ -0,0 +1,114 @@ +from argparse import Namespace +from collections import defaultdict +from collections.abc import Callable, Iterable +from dataclasses import dataclass +from enum import Enum, auto +from functools import partial +from graphlib import TopologicalSorter +from typing import Any, Generic, TypeVar + +T = TypeVar("T") + + +@dataclass +class Derived: + fn: Callable + prev: set[str] + final: set[str] + + +def derived(*, prev: set[str] | None = None, final: set[str] | None = None) -> Callable[[Callable], Derived]: + return partial(Derived, prev=prev or set(), final=final or set()) + + +class Required(Enum): + REQUIRED = auto() + + +def required() -> Required: + return Required.REQUIRED + + +class NotSet(Enum): + NOT_SET = auto() + + +def not_set() -> NotSet: + return NotSet.NOT_SET + + +class SettingResolver(Generic[T]): + @staticmethod + def iter_settings(namespace: Any) -> Iterable[str]: + for name in dir(namespace): + if name == name.upper() and not name.startswith("_"): + yield name + + def __init__(self) -> None: + self.layers: list[dict[str, T | Derived | Required | NotSet]] = [] + self.layers.append(defaultdict(not_set)) # base level: nothing is set + + def add_layer(self, namespace: Any) -> None: + self.layers.append({name: getattr(namespace, name) for name in self.iter_settings(namespace)}) + + @property + def final_layer(self) -> int: + return len(self.layers) - 1 + + def all_setting_names(self) -> Iterable[str]: + return {name for layer in self.layers for name in layer} + + def get_setting_at_layer(self, name, layer_index) -> T | Required | NotSet: + for i in range(layer_index, 0, -1): + if name in self.layers[i]: + value = self.layers[i][name] + assert not isinstance(value, Derived) + return value + return NotSet.NOT_SET + + def compute_derived_values(self) -> None: + sorter = TopologicalSorter[tuple[int, str]]() + + for i, layer in enumerate(self.layers): + for name, value in layer.items(): + deps: list[tuple[int, str]] = [] + if isinstance(value, Derived): + deps.extend((i - 1, dep_name) for dep_name in value.prev) + deps.extend((self.final_layer, dep_name) for dep_name in value.final) + sorter.add((i, name), *deps) + + for index, name in sorter.static_order(): + match self.layers[index].get(name): + case Derived(fn, prev, final): + prev_ns = Namespace(**{name: self.get_setting_at_layer(name, index - 1) for name in prev}) + final_ns = Namespace(**{name: self.get_setting_at_layer(name, self.final_layer) for name in final}) + self.layers[index][name] = fn(prev_ns, final_ns) + + def get_final_values(self, keys: Iterable[str]) -> dict[str, T]: + resolved = {} + missing_settings = set() + for name in keys: + value = self.get_setting_at_layer(name, self.final_layer) + match value: + case Derived(): + raise AssertionError("derived values should have been resolved by now") + case Required.REQUIRED: + missing_settings.add(name) + case NotSet.NOT_SET: + pass + case _: + resolved[name] = value + if missing_settings: + raise ValueError(f"The following settings must be set: {', '.join(missing_settings)}") + return resolved + + def resolve(self) -> dict[str, T]: + self.compute_derived_values() + return self.get_final_values(self.all_setting_names()) + + +def resolve(namespaces: list[Any]) -> dict[str, Any]: + resolver = SettingResolver[Any]() + for ns in namespaces: + resolver.add_layer(ns) + return resolver.resolve() diff --git a/evap/new_settings/open_id.py b/evap/new_settings/open_id.py new file mode 100644 index 0000000000..55fa2c667a --- /dev/null +++ b/evap/new_settings/open_id.py @@ -0,0 +1,25 @@ +from evap.new_settings.lazy import required + +ACTIVATE_OPEN_ID_LOGIN = required() + +# replace 'example.com', OIDC_RP_CLIENT_ID and OIDC_RP_CLIENT_SECRET with real values in localsettings when activating +OIDC_RENEW_ID_TOKEN_EXPIRY_SECONDS = 60 * 60 * 24 * 7 # one week +OIDC_RP_SIGN_ALGO = "RS256" +OIDC_USERNAME_ALGO = "" +OIDC_RP_SCOPES = "openid email profile" + +OIDC_RP_CLIENT_ID = "evap" +OIDC_RP_CLIENT_SECRET = "evap-secret" # nosec + +OIDC_OP_AUTHORIZATION_ENDPOINT = "https://example.com/auth" +OIDC_OP_TOKEN_ENDPOINT = "https://example.com/token" # nosec +OIDC_OP_USER_ENDPOINT = "https://example.com/me" +OIDC_OP_JWKS_ENDPOINT = "https://example.com/certs" + +# Mapping of email domain transition which users may undergo during the lifetime of their account. +# Given an item (k, v) of the mapping, if an account with domain k would be created through OpenID, +# but an account with the same name at domain v exists, the existing account is migrated to the +# domain k instead. +OIDC_EMAIL_TRANSITIONS: dict[str, str] = { + "institution.example.com": "student.institution.example.com", +} diff --git a/evap/new_settings/test.py b/evap/new_settings/test.py new file mode 100644 index 0000000000..cc8ca684f2 --- /dev/null +++ b/evap/new_settings/test.py @@ -0,0 +1,49 @@ +import sys +from copy import deepcopy + +from model_bakery import random_gen + +from evap.new_settings.lazy import derived + +TEST_RUNNER = "evap.evaluation.tests.tools.EvapTestRunner" +TESTING = "test" in sys.argv or "pytest" in sys.modules + + +@derived(prev={"STORAGES"}, final={"TESTING"}) +def STORAGES(prev, final): + if final.TESTING: + storages = deepcopy(prev.STORAGES) + # do not use ManifestStaticFilesStorage as it requires running collectstatic beforehand + storages["staticfiles"]["BACKEND"] = "django.contrib.staticfiles.storage.StaticFilesStorage" + return storages + return prev.STORAGES + + +@derived(prev={"CACHES"}, final={"TESTING"}) +def CACHES(prev, final): + if final.TESTING: + # use the database for caching. it's properly reset between tests in constrast to redis, + # and does not change behaviour in contrast to disabling the cache entirely. + return { + "default": { + "BACKEND": "django.core.cache.backends.db.DatabaseCache", + "LOCATION": "testing_cache_default", + }, + "results": { + "BACKEND": "django.core.cache.backends.db.DatabaseCache", + "LOCATION": "testing_cache_results", + }, + "sessions": { + "BACKEND": "django.core.cache.backends.db.DatabaseCache", + "LOCATION": "testing_cache_sessions", + }, + } + return prev.CACHES + + +@derived(prev={"BAKER_CUSTOM_FIELDS_GEN"}, final={"TESTING"}) +def BAKER_CUSTOM_FIELDS_GEN(prev, final): + if final.TESTING: + # give random char field values a reasonable length + return {"django.db.models.CharField": lambda: random_gen.gen_string(20)} + return prev.BAKER_CUSTOM_FIELDS_GEN From bde912814035b602ce962949c189afabbfddab93 Mon Sep 17 00:00:00 2001 From: Niklas Mohrin Date: Mon, 22 Sep 2025 18:17:07 +0200 Subject: [PATCH 02/22] move new_settings module to just settings --- evap/settings.py | 510 ------------------ evap/{new_settings => settings}/__init__.py | 0 .../debug_toolbar.py | 4 +- evap/{new_settings => settings}/default.py | 4 +- evap/{new_settings => settings}/dev.py | 2 +- evap/{new_settings => settings}/open_id.py | 2 +- evap/{new_settings => settings}/test.py | 2 +- .../lazy.py => settings_resolver.py} | 2 +- 8 files changed, 8 insertions(+), 518 deletions(-) delete mode 100644 evap/settings.py rename evap/{new_settings => settings}/__init__.py (100%) rename evap/{new_settings => settings}/debug_toolbar.py (89%) rename evap/{new_settings => settings}/default.py (98%) rename evap/{new_settings => settings}/dev.py (92%) rename evap/{new_settings => settings}/open_id.py (95%) rename evap/{new_settings => settings}/test.py (97%) rename evap/{new_settings/lazy.py => settings_resolver.py} (98%) diff --git a/evap/settings.py b/evap/settings.py deleted file mode 100644 index a2aad495f7..0000000000 --- a/evap/settings.py +++ /dev/null @@ -1,510 +0,0 @@ -""" -Django settings for EvaP project. - -For more information on this file, see -https://docs.djangoproject.com/en/2.0/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/2.0/ref/settings/ -""" - -import logging -import sys -from datetime import timedelta -from fractions import Fraction -from pathlib import Path -from typing import Any - -from django.contrib.staticfiles.storage import ManifestStaticFilesStorage - -from evap.tools import MonthAndDay - -MODULE = Path(__file__).parent.resolve() -CWD = Path(".").resolve() -DATADIR = CWD / "data" - -### Debugging - -DEBUG = True - -# Very helpful but eats a lot of performance on sql-heavy pages. -# Works only with DEBUG = True and Django's development server (so no apache). -ENABLE_DEBUG_TOOLBAR = False - -### EvaP logic - -LOGIN_KEY_VALIDITY = 210 # days, so roughly 7 months - -VOTER_COUNT_NEEDED_FOR_PUBLISHING_RATING_RESULTS = 2 -VOTER_PERCENTAGE_NEEDED_FOR_PUBLISHING_AVERAGE_GRADE = 0.2 -SMALL_COURSE_SIZE = 5 # up to which number of participants the evaluation gets additional warnings about anonymity -PARTICIPATION_DELETION_AFTER_INACTIVE_TIME = timedelta(days=18 * 30) - -# a warning is shown next to results where less than RESULTS_WARNING_COUNT answers were given -# or the number of answers is less than RESULTS_WARNING_PERCENTAGE times the median number of answers (for this question in this evaluation) -RESULTS_WARNING_COUNT = 4 -RESULTS_WARNING_PERCENTAGE = 0.5 - -## percentages for calculating an evaluation's total average grade -# grade questions are weighted this much for each contributor's average grade -CONTRIBUTOR_GRADE_QUESTIONS_WEIGHT = 4 -# non-grade questions are weighted this much for each contributor's average grade -CONTRIBUTOR_NON_GRADE_RATING_QUESTIONS_WEIGHT = 6 -# the average contribution grade is weighted this much for the evaluation's average grade -CONTRIBUTIONS_WEIGHT = 1 -# the average grade of all general grade questions is weighted this much for the evaluation's average grade -GENERAL_GRADE_QUESTIONS_WEIGHT = 1 -# the average grade of all general non-grade questions is weighted this much for the evaluation's average grade -GENERAL_NON_GRADE_QUESTIONS_WEIGHT = 1 - -# number of reward points a student should have for a semester after evaluating the given fraction of evaluations. -REWARD_POINTS = [ - (1 / 3, 1), - (2 / 3, 2), - (3 / 3, 3), -] - -# days before end date to send reminder -REMIND_X_DAYS_AHEAD_OF_END_DATE = [2, 0] - -# days of the week on which managers are reminded to handle urgent text answer reviews -# where Monday is 0 and Sunday is 6 -TEXTANSWER_REVIEW_REMINDER_WEEKDAYS = [3] - -# Email addresses that are reminded about uploading grade documents -GRADE_REMINDER_EMAIL_RECIPIENTS: list[str] = [] -# Dates on which grade upload reminder emails are sent. -GRADE_REMINDER_EMAIL_DATES = [ - MonthAndDay(month=3, day=15), - MonthAndDay(month=9, day=15), -] - -# email domains for the internal users of the hosting institution used to -# figure out who is an internal user -INSTITUTION_EMAIL_DOMAINS: list[str] = ["institution.example.com", "student.institution.example.com"] - -# List of tuples defining email domains that should be replaced on saving UserProfiles. -# Emails ending on the first value will have this part replaced by the second value. -# e.g.: [("institution.example.com", "institution.com")] -INSTITUTION_EMAIL_REPLACEMENTS: list[tuple[str, str]] = [] - -# the importer accepts only these strings in the 'graded' column -IMPORTER_GRADED_YES = ["yes", "ja", "graded", "benotet"] -IMPORTER_GRADED_NO = ["no", "nein", "ungraded", "unbenotet"] - -# the importer will warn if any participant has more enrollments than this number -IMPORTER_MAX_ENROLLMENTS = 7 - -# Cutoff value passed to difflib.get_close_matches() to find typos in course names. Lower values are slower. -IMPORTER_COURSE_NAME_SIMILARITY_WARNING_THRESHOLD = 0.9 - -# the default descriptions for grade documents -DEFAULT_FINAL_GRADES_DESCRIPTION_EN = "Final grades" -DEFAULT_MIDTERM_GRADES_DESCRIPTION_EN = "Midterm grades" -DEFAULT_FINAL_GRADES_DESCRIPTION_DE = "Endnoten" -DEFAULT_MIDTERM_GRADES_DESCRIPTION_DE = "Zwischennoten" - -# Specify an offset that will be added to the evaluation end date (e.g. 3: If the end date is 01.01., the evaluation will end at 02.01. 03:00.). -EVALUATION_END_OFFSET_HOURS = 3 - -# Amount of hours in which participant will be warned -EVALUATION_END_WARNING_PERIOD = 5 - -# Questionnaires automatically added to exam evaluations -EXAM_QUESTIONNAIRE_IDS: list[int] = [] - -# Emails of users that shouldn't be imported as responsibles during JSON import -NON_RESPONSIBLE_USERS: set[str] = set() - -# Study programs that shouldn't be imported during JSON import -IGNORE_PROGRAMS: set[str] = set() - -### Installation specific settings - -# People who get emails on errors. -ADMINS: list[tuple[str, str]] = [ - # ('Your Name', 'your_email@example.com'), -] - -# The page URL that is used in email templates. -PAGE_URL = "localhost:8000" - -DATABASES = { - "default": { - "ENGINE": "django.db.backends.postgresql", - "NAME": "evap", - "USER": "evap", - "PASSWORD": "evap", - "HOST": "localhost", - "CONN_MAX_AGE": 600, - } -} - -CACHES = { - "default": { - "BACKEND": "django.core.cache.backends.redis.RedisCache", - "LOCATION": "redis://127.0.0.1:6379/0", - }, - "results": { - "BACKEND": "django.core.cache.backends.redis.RedisCache", - "LOCATION": "redis://127.0.0.1:6379/1", - "TIMEOUT": None, # is always invalidated manually - }, - "sessions": { - "BACKEND": "django.core.cache.backends.redis.RedisCache", - "LOCATION": "redis://127.0.0.1:6379/2", - }, -} - - -class ManifestStaticFilesStorageWithJsReplacement(ManifestStaticFilesStorage): - support_js_module_import_aggregation = True - - -STORAGES = { - "default": { - "BACKEND": "django.core.files.storage.FileSystemStorage", - }, - "staticfiles": { - "BACKEND": "evap.settings.ManifestStaticFilesStorageWithJsReplacement", - }, -} - -CONTACT_EMAIL = "webmaster@localhost" -ALLOW_ANONYMOUS_FEEDBACK_MESSAGES = True -LEGAL_NOTICE_TEXT = "Objection! (this is a default setting that the administrators should change, please contact them)" - -# Config for mail system -DEFAULT_FROM_EMAIL = "webmaster@localhost" -REPLY_TO_EMAIL = DEFAULT_FROM_EMAIL -SEND_ALL_EMAILS_TO_ADMINS_IN_BCC = False -if DEBUG: - EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" - - -LOGGING = { - "version": 1, - "disable_existing_loggers": False, - "formatters": { - "default": { - "format": "[%(asctime)s] %(levelname)s: %(message)s", - }, - }, - "handlers": { - "file": { - "level": "DEBUG", - "class": "logging.handlers.RotatingFileHandler", - "filename": DATADIR / "evap.log", - "maxBytes": 1024 * 1024 * 10, - "backupCount": 5, - "formatter": "default", - }, - "mail_admins": { - "level": "ERROR", - "class": "django.utils.log.AdminEmailHandler", - }, - "console": { - "class": "logging.StreamHandler", - "formatter": "default", - }, - }, - "loggers": { - "django": { - "handlers": ["console", "file", "mail_admins"], - "level": "INFO", - "propagate": True, - }, - "evap": { - "handlers": ["console", "file", "mail_admins"], - "level": "DEBUG", - "propagate": True, - }, - "mozilla_django_oidc": { - "handlers": ["console", "file", "mail_admins"], - "level": "DEBUG", - "propagate": True, - }, - }, -} - - -### Application definition - -AUTH_USER_MODEL = "evaluation.UserProfile" - -INSTALLED_APPS = [ - "django.contrib.auth", - "django.contrib.contenttypes", - "django.contrib.sessions", - "django.contrib.messages", - "django.contrib.staticfiles", - "django_extensions", - "evap.evaluation", - "evap.staff", - "evap.results", - "evap.student", - "evap.contributor", - "evap.rewards", - "evap.grades", - "django.forms", - "mozilla_django_oidc", -] - -MIDDLEWARE = [ - "django.middleware.security.SecurityMiddleware", - "django.contrib.sessions.middleware.SessionMiddleware", - # LocaleMiddleware should be here according to https://docs.djangoproject.com/en/2.2/topics/i18n/translation/#how-django-discovers-language-preference - "django.middleware.locale.LocaleMiddleware", - "django.middleware.common.CommonMiddleware", - "django.middleware.csrf.CsrfViewMiddleware", - "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.messages.middleware.MessageMiddleware", - "django.middleware.clickjacking.XFrameOptionsMiddleware", - "mozilla_django_oidc.middleware.SessionRefresh", - "evap.middleware.RequireLoginMiddleware", - "evap.middleware.user_language_middleware", - "evap.staff.staff_mode.staff_mode_middleware", - "evap.evaluation.middleware.LoggingRequestMiddleware", -] - -_TEMPLATE_OPTIONS = { - "context_processors": [ - "django.contrib.auth.context_processors.auth", - "django.template.context_processors.debug", - "django.template.context_processors.i18n", - "django.template.context_processors.static", - "django.template.context_processors.request", - "django.contrib.messages.context_processors.messages", - "evap.context_processors.slogan", - "evap.context_processors.debug", - "evap.context_processors.notebook_form", - "evap.context_processors.allow_anonymous_feedback_messages", - ], - "builtins": ["django.templatetags.i18n"], -} - - -TEMPLATES: Any = [ - { - "BACKEND": "django.template.backends.django.DjangoTemplates", - "APP_DIRS": True, - "OPTIONS": _TEMPLATE_OPTIONS, - "NAME": "MainEngine", - }, - { - "BACKEND": "django.template.backends.django.DjangoTemplates", - "APP_DIRS": True, - "OPTIONS": {**_TEMPLATE_OPTIONS, "debug": False}, - "NAME": "CachedEngine", # used for bulk-filling caches - }, -] - -# This allows to redefine form widget templates used by Django when generating forms. -# The templates are located in evaluation/templates/django/forms/widgets and add the "form-control" class for correct bootstrap styling. -FORM_RENDERER = "django.forms.renderers.TemplatesSetting" - -AUTHENTICATION_BACKENDS = [ - "evap.evaluation.auth.RequestAuthUserBackend", - "evap.evaluation.auth.OpenIDAuthenticationBackend", - "evap.evaluation.auth.EmailAuthenticationBackend", -] - -DEFAULT_AUTO_FIELD = "django.db.models.AutoField" - -ROOT_URLCONF = "evap.urls" - -WSGI_APPLICATION = "evap.wsgi.application" - -LOGIN_REDIRECT_URL = "/" -LOGOUT_REDIRECT_URL = "/" - -LOGIN_URL = "/" - -SESSION_ENGINE = "django.contrib.sessions.backends.cache" -SESSION_CACHE_ALIAS = "sessions" - -SESSION_SAVE_EVERY_REQUEST = True -SESSION_COOKIE_AGE = 60 * 60 * 24 * 365 # one year - -STAFF_MODE_TIMEOUT = 3 * 60 * 60 # three hours -STAFF_MODE_INFO_TIMEOUT = 3 * 60 * 60 # three hours - -# Disable the check for number of post parameters to enable, e.g., large numbers of participants in forms -# see https://docs.djangoproject.com/en/5.0/ref/settings/#data-upload-max-number-fields -DATA_UPLOAD_MAX_NUMBER_FIELDS = None - - -### Internationalization - -LANGUAGE_CODE = "en" - -TIME_ZONE = "Europe/Berlin" - -USE_I18N = True - -USE_TZ = False - -LOCALE_PATHS = [MODULE / "locale"] - -FORMAT_MODULE_PATH = ["evap.locale"] - -LANGUAGES = [ - ("en", "English"), - ("de", "Deutsch"), -] - - -### Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/2.0/howto/static-files/ - -STATIC_URL = "/static/" - -# Additional locations of static files -STATICFILES_DIRS = [ - MODULE / "static", -] - -# Absolute path to the directory static files should be collected to. -STATIC_ROOT = DATADIR / "static_collected" - - -### User-uploaded files - -# Absolute filesystem path to the directory that will hold user-uploaded files. -MEDIA_ROOT = DATADIR / "upload" - -### Evaluation progress rewards -GLOBAL_EVALUATION_PROGRESS_REWARDS: list[tuple[Fraction, str]] = ( - [] -) # (required_voter_ratio between 0 and 1, reward_text) -GLOBAL_EVALUATION_PROGRESS_EXCLUDED_COURSE_TYPE_IDS: list[int] = [] -GLOBAL_EVALUATION_PROGRESS_EXCLUDED_EVALUATION_IDS: list[int] = [] -GLOBAL_EVALUATION_PROGRESS_INFO_TEXT: dict[str, str] = {"de": "", "en": ""} - -### Slogans -SLOGANS_DE = [ - "Evaluierungen verlässlich ausführen und präsentieren", - "Entscheidungsgrundlage zur Verbesserung akademischer Programme", - "Ein voll atemberaubendes Projekt", - "Evaluierungs-Vereinfachung aus Potsdam", - "Elegante Verwaltung automatisierter Performancemessungen", - "Effektive Vermeidung von anstrengendem Papierkram", - "Einfach Verantwortlichen Abstimmungsergebnisse präsentieren", - "Ein Vorzeigeprojekt auf Python-Basis", - "Erleichtert Verfolgung aufgetretener Probleme", - "Entwickelt von arbeitsamen Personen", -] -SLOGANS_EN = [ - "Extremely valuable automated processing", - "Exploring various answers professionally", - "Encourages values and perfection", - "Enables virtuously adressed petitions", - "Evades very annoying paperwork", - "Engineered voluntarily and passionately", - "Elegant valiantly administered platform", - "Efficient voting and processing", - "Everyone values awesome products", - "Enhances vibrant academic programs", -] - - -### Allowed chosen first names / display names -def CHARACTER_ALLOWED_IN_NAME(character): # pylint: disable=invalid-name - return any( - ( - ord(character) in range(32, 127), # printable ASCII / Basic Latin characters - ord(character) in range(160, 256), # printable Latin-1 Supplement characters - ord(character) in range(256, 384), # Latin Extended-A - ) - ) - - -### OpenID Login -# replace 'example.com', OIDC_RP_CLIENT_ID and OIDC_RP_CLIENT_SECRET with real values in localsettings when activating -ACTIVATE_OPEN_ID_LOGIN = False -OIDC_RENEW_ID_TOKEN_EXPIRY_SECONDS = 60 * 60 * 24 * 7 # one week -OIDC_RP_SIGN_ALGO = "RS256" -OIDC_USERNAME_ALGO = "" -OIDC_RP_SCOPES = "openid email profile" - -OIDC_RP_CLIENT_ID = "evap" -OIDC_RP_CLIENT_SECRET = "evap-secret" # nosec - -OIDC_OP_AUTHORIZATION_ENDPOINT = "https://example.com/auth" -OIDC_OP_TOKEN_ENDPOINT = "https://example.com/token" # nosec -OIDC_OP_USER_ENDPOINT = "https://example.com/me" -OIDC_OP_JWKS_ENDPOINT = "https://example.com/certs" - -# Mapping of email domain transition which users may undergo during the lifetime of their account. -# Given an item (k, v) of the mapping, if an account with domain k would be created through OpenID, -# but an account with the same name at domain v exists, the existing account is migrated to the -# domain k instead. -OIDC_EMAIL_TRANSITIONS: dict[str, str] = { - "institution.example.com": "student.institution.example.com", -} - - -### Other - -# Create a localsettings.py if you want to locally override settings -# and don't want the changes to appear in 'git status'. -try: - # localsettings file may or may not exist (for example in CI) - - # the import can overwrite locals with a slightly different type (e.g. DATABASES), which is fine. - from evap.localsettings import * # type: ignore # noqa: F403,PGH003 -except ImportError: - pass - -TEST_RUNNER = "evap.evaluation.tests.tools.EvapTestRunner" -TESTING = "test" in sys.argv or "pytest" in sys.modules - -# speed up tests and activate typeguard introspection -if TESTING: - from typeguard import install_import_hook - - install_import_hook(("evap", "tools")) - - # do not use ManifestStaticFilesStorage as it requires running collectstatic beforehand - STORAGES["staticfiles"]["BACKEND"] = "django.contrib.staticfiles.storage.StaticFilesStorage" - - logging.disable(logging.CRITICAL) # disable logging, primarily to prevent console spam - - # use the database for caching. it's properly reset between tests in constrast to redis, - # and does not change behaviour in contrast to disabling the cache entirely. - CACHES = { - "default": { - "BACKEND": "django.core.cache.backends.db.DatabaseCache", - "LOCATION": "testing_cache_default", - }, - "results": { - "BACKEND": "django.core.cache.backends.db.DatabaseCache", - "LOCATION": "testing_cache_results", - }, - "sessions": { - "BACKEND": "django.core.cache.backends.db.DatabaseCache", - "LOCATION": "testing_cache_sessions", - }, - } - from model_bakery import random_gen - - # give random char field values a reasonable length - BAKER_CUSTOM_FIELDS_GEN = {"django.db.models.CharField": lambda: random_gen.gen_string(20)} - - -# Development helpers -if DEBUG: - INSTALLED_APPS += ["evap.development"] - - # Django debug toolbar settings - if not TESTING and ENABLE_DEBUG_TOOLBAR: - INSTALLED_APPS += ["debug_toolbar"] - MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware"] + MIDDLEWARE - - def show_toolbar(request): - return True - - DEBUG_TOOLBAR_CONFIG = { - "SHOW_TOOLBAR_CALLBACK": "evap.settings.show_toolbar", - "JQUERY_URL": "", - } diff --git a/evap/new_settings/__init__.py b/evap/settings/__init__.py similarity index 100% rename from evap/new_settings/__init__.py rename to evap/settings/__init__.py diff --git a/evap/new_settings/debug_toolbar.py b/evap/settings/debug_toolbar.py similarity index 89% rename from evap/new_settings/debug_toolbar.py rename to evap/settings/debug_toolbar.py index ac2f69314d..b52d06b67b 100644 --- a/evap/new_settings/debug_toolbar.py +++ b/evap/settings/debug_toolbar.py @@ -1,4 +1,4 @@ -from evap.new_settings.lazy import derived +from evap.settings_resolver import derived # Very helpful but eats a lot of performance on sql-heavy pages. # Works only with DEBUG = True and Django's development server (so no apache). @@ -32,7 +32,7 @@ def show_toolbar(request): def DEBUG_TOOLBAR_CONFIG(prev, final): if final.REALLY_ENABLE_DEBUG_TOOLBAR: return { - "SHOW_TOOLBAR_CALLBACK": "evap.new_settings.debug_toolbar.show_toolbar", + "SHOW_TOOLBAR_CALLBACK": "evap.settings.debug_toolbar.show_toolbar", "JQUERY_URL": "", } return prev.DEBUG_TOOLBAR_CONFIG diff --git a/evap/new_settings/default.py b/evap/settings/default.py similarity index 98% rename from evap/new_settings/default.py rename to evap/settings/default.py index 6c0fd1c04c..08685e2573 100644 --- a/evap/new_settings/default.py +++ b/evap/settings/default.py @@ -8,7 +8,7 @@ from django.contrib.staticfiles.storage import ManifestStaticFilesStorage import evap -from evap.new_settings.lazy import derived, required +from evap.settings_resolver import derived, required from evap.tools import MonthAndDay @@ -159,7 +159,7 @@ def DATADIR(prev, final): "BACKEND": "django.core.files.storage.FileSystemStorage", }, "staticfiles": { - "BACKEND": "evap.new_settings.common.ManifestStaticFilesStorageWithJsReplacement", + "BACKEND": "evap.settingsettings.common.ManifestStaticFilesStorageWithJsReplacement", }, } diff --git a/evap/new_settings/dev.py b/evap/settings/dev.py similarity index 92% rename from evap/new_settings/dev.py rename to evap/settings/dev.py index 1df76e10a4..7c9974ae0f 100644 --- a/evap/new_settings/dev.py +++ b/evap/settings/dev.py @@ -1,4 +1,4 @@ -from evap.new_settings.lazy import derived +from evap.settings_resolver import derived PAGE_URL = "localhost:8000" ACTIVATE_OPEN_ID_LOGIN = False diff --git a/evap/new_settings/open_id.py b/evap/settings/open_id.py similarity index 95% rename from evap/new_settings/open_id.py rename to evap/settings/open_id.py index 55fa2c667a..b6a67a35a8 100644 --- a/evap/new_settings/open_id.py +++ b/evap/settings/open_id.py @@ -1,4 +1,4 @@ -from evap.new_settings.lazy import required +from evap.settings_resolver import required ACTIVATE_OPEN_ID_LOGIN = required() diff --git a/evap/new_settings/test.py b/evap/settings/test.py similarity index 97% rename from evap/new_settings/test.py rename to evap/settings/test.py index cc8ca684f2..cef5843d8a 100644 --- a/evap/new_settings/test.py +++ b/evap/settings/test.py @@ -3,7 +3,7 @@ from model_bakery import random_gen -from evap.new_settings.lazy import derived +from evap.settings_resolver import derived TEST_RUNNER = "evap.evaluation.tests.tools.EvapTestRunner" TESTING = "test" in sys.argv or "pytest" in sys.modules diff --git a/evap/new_settings/lazy.py b/evap/settings_resolver.py similarity index 98% rename from evap/new_settings/lazy.py rename to evap/settings_resolver.py index aec916e733..67eaa12a42 100644 --- a/evap/new_settings/lazy.py +++ b/evap/settings_resolver.py @@ -107,7 +107,7 @@ def resolve(self) -> dict[str, T]: return self.get_final_values(self.all_setting_names()) -def resolve(namespaces: list[Any]) -> dict[str, Any]: +def resolve_settings(namespaces: list[Any]) -> dict[str, Any]: resolver = SettingResolver[Any]() for ns in namespaces: resolver.add_layer(ns) From cb3a2b5162cfbdc31e7659244e181ba882aedf70 Mon Sep 17 00:00:00 2001 From: Niklas Mohrin Date: Mon, 22 Sep 2025 18:44:11 +0200 Subject: [PATCH 03/22] remove localsettings template --- evap/development/localsettings.template.py | 59 ---------------------- evap/settings/default.py | 3 ++ evap/settings/dev.py | 22 ++++++++ evap/settings/local_services.py | 40 +++++++++++++++ evap/settings_test.py | 30 ----------- 5 files changed, 65 insertions(+), 89 deletions(-) delete mode 100644 evap/development/localsettings.template.py create mode 100644 evap/settings/local_services.py delete mode 100644 evap/settings_test.py diff --git a/evap/development/localsettings.template.py b/evap/development/localsettings.template.py deleted file mode 100644 index 0258d5c4f2..0000000000 --- a/evap/development/localsettings.template.py +++ /dev/null @@ -1,59 +0,0 @@ -# noqa: N999 - -from fractions import Fraction -from pathlib import Path - -from django.utils.safestring import mark_safe - -DATABASES = { - "default": { - "ENGINE": "django.db.backends.postgresql", - "NAME": "evap", - "USER": "evap", - "PASSWORD": "evap", - # Absolute path to use unix domain socket - "HOST": Path("./data/").resolve(), - "CONN_MAX_AGE": 600, - } -} - -REDIS_URL = f"unix://{Path('./data/redis.socket').resolve()}" - -CACHES = { - "default": { - "BACKEND": "django.core.cache.backends.redis.RedisCache", - "LOCATION": f"{REDIS_URL}?db=0", - }, - "results": { - "BACKEND": "django.core.cache.backends.redis.RedisCache", - "LOCATION": f"{REDIS_URL}?db=1", - "TIMEOUT": None, # is always invalidated manually - }, - "sessions": { - "BACKEND": "django.core.cache.backends.redis.RedisCache", - "LOCATION": f"{REDIS_URL}?db=2", - }, -} - -# Make this unique, and don't share it with anybody. -SECRET_KEY = "$SECRET_KEY" # nosec - -# Make apache work when DEBUG == False -ALLOWED_HOSTS = ["localhost", "127.0.0.1"] - -### Evaluation progress rewards -GLOBAL_EVALUATION_PROGRESS_REWARDS: list[tuple[Fraction, str]] = [ - (Fraction("0"), "0€"), - (Fraction("0.25"), "1.000€"), - (Fraction("0.6"), "3.000€"), - (Fraction("0.7"), "7.000€"), - (Fraction("0.9"), "10.000€"), -] -GLOBAL_EVALUATION_PROGRESS_EXCLUDED_COURSE_TYPE_IDS: list[int] = [] -GLOBAL_EVALUATION_PROGRESS_EXCLUDED_EVALUATION_IDS: list[int] = [] -GLOBAL_EVALUATION_PROGRESS_INFO_TEXT = { - "de": mark_safe("Deine Teilnahme am Evaluationsprojekt wird helfen. Evaluiere also jetzt!"), - "en": mark_safe("Your participation in the evaluation helps, so evaluate now!"), -} -# Questionnaires automatically added to exam evaluations -EXAM_QUESTIONNAIRE_IDS = [111] diff --git a/evap/settings/default.py b/evap/settings/default.py index 08685e2573..cc27e272c3 100644 --- a/evap/settings/default.py +++ b/evap/settings/default.py @@ -127,6 +127,9 @@ def DATADIR(prev, final): # The page URL that is used in email templates. PAGE_URL = required() +# Key used for Django's signing module +SECRET_KEY = required() + DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql", diff --git a/evap/settings/dev.py b/evap/settings/dev.py index 7c9974ae0f..a5b0698273 100644 --- a/evap/settings/dev.py +++ b/evap/settings/dev.py @@ -1,8 +1,13 @@ +from fractions import Fraction from evap.settings_resolver import derived +from django.utils.safestring import mark_safe PAGE_URL = "localhost:8000" ACTIVATE_OPEN_ID_LOGIN = False +# Make apache work when DEBUG == False +ALLOWED_HOSTS = ["localhost", "127.0.0.1"] + @derived(prev={"INSTALLED_APPS"}, final={"DEBUG"}) def INSTALLED_APPS(prev, final): @@ -17,3 +22,20 @@ def INSTALLED_APPS(prev, final): SEND_ALL_EMAILS_TO_ADMINS_IN_BCC = False LEGAL_NOTICE_TEXT = "Objection! (this is a default setting that the administrators should change, please contact them)" + +### Evaluation progress rewards +GLOBAL_EVALUATION_PROGRESS_REWARDS: list[tuple[Fraction, str]] = [ + (Fraction("0"), "0€"), + (Fraction("0.25"), "1.000€"), + (Fraction("0.6"), "3.000€"), + (Fraction("0.7"), "7.000€"), + (Fraction("0.9"), "10.000€"), +] +GLOBAL_EVALUATION_PROGRESS_EXCLUDED_COURSE_TYPE_IDS: list[int] = [] +GLOBAL_EVALUATION_PROGRESS_EXCLUDED_EVALUATION_IDS: list[int] = [] +GLOBAL_EVALUATION_PROGRESS_INFO_TEXT = { + "de": mark_safe("Deine Teilnahme am Evaluationsprojekt wird helfen. Evaluiere also jetzt!"), + "en": mark_safe("Your participation in the evaluation helps, so evaluate now!"), +} +# Questionnaires automatically added to exam evaluations +EXAM_QUESTIONNAIRE_IDS = [111] diff --git a/evap/settings/local_services.py b/evap/settings/local_services.py new file mode 100644 index 0000000000..548f7a058a --- /dev/null +++ b/evap/settings/local_services.py @@ -0,0 +1,40 @@ +from evap.settings_resolver import derived + + +@derived(final={"DATADIR"}) +def DATABASES(prev, final): + return { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": "evap", + "USER": "evap", + "PASSWORD": "evap", + # Absolute path to use unix domain socket + "HOST": final.DATADIR.resolve(), + "CONN_MAX_AGE": 600, + } + } + + +@derived(final={"DATADIR"}) +def REDIS_URL(prev, final): + return f"unix://{(final.DATADIR / 'redis.socket').resolve()}" + + +@derived(final={"REDIS_URL"}) +def CACHES(prev, final): + return { + "default": { + "BACKEND": "django.core.cache.backends.redis.RedisCache", + "LOCATION": f"{final.REDIS_URL}?db=0", + }, + "results": { + "BACKEND": "django.core.cache.backends.redis.RedisCache", + "LOCATION": f"{final.REDIS_URL}?db=1", + "TIMEOUT": None, # is always invalidated manually + }, + "sessions": { + "BACKEND": "django.core.cache.backends.redis.RedisCache", + "LOCATION": f"{final.REDIS_URL}?db=2", + }, + } diff --git a/evap/settings_test.py b/evap/settings_test.py deleted file mode 100644 index 447b6cbc05..0000000000 --- a/evap/settings_test.py +++ /dev/null @@ -1,30 +0,0 @@ -from pathlib import Path - -SECRET_KEY = "evap-github-actions-secret-key" # nosec -DATABASES = { - "default": { - "ENGINE": "django.db.backends.postgresql", - "NAME": "evap", - "USER": "evap", - "PASSWORD": "evap", - "HOST": Path("./data/").resolve(), - } -} - -REDIS_URL = f"unix://{Path('./data/redis.socket').resolve()}" - -CACHES = { - "default": { - "BACKEND": "django.core.cache.backends.redis.RedisCache", - "LOCATION": f"{REDIS_URL}?db=0", - }, - "results": { - "BACKEND": "django.core.cache.backends.redis.RedisCache", - "LOCATION": f"{REDIS_URL}?db=1", - "TIMEOUT": None, # is always invalidated manually - }, - "sessions": { - "BACKEND": "django.core.cache.backends.redis.RedisCache", - "LOCATION": f"{REDIS_URL}?db=2", - }, -} From 793a8c89f18dda1e872ba4341b802b2d51f107e2 Mon Sep 17 00:00:00 2001 From: Niklas Mohrin Date: Mon, 22 Sep 2025 18:49:05 +0200 Subject: [PATCH 04/22] settingsettings --- evap/settings/default.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evap/settings/default.py b/evap/settings/default.py index cc27e272c3..e0f470c4cf 100644 --- a/evap/settings/default.py +++ b/evap/settings/default.py @@ -162,7 +162,7 @@ def DATADIR(prev, final): "BACKEND": "django.core.files.storage.FileSystemStorage", }, "staticfiles": { - "BACKEND": "evap.settingsettings.common.ManifestStaticFilesStorageWithJsReplacement", + "BACKEND": "evap.settings.common.ManifestStaticFilesStorageWithJsReplacement", }, } From 3de9d4f68bb4accaf1e321651250ed45cc273953 Mon Sep 17 00:00:00 2001 From: Niklas Mohrin Date: Mon, 22 Sep 2025 19:42:38 +0200 Subject: [PATCH 05/22] set DJANGO_SETTINGS_MODULE in nix shell instead of in __main__.py --- .gitignore | 1 + evap/__main__.py | 3 --- evap/development/localsettings.template.py | 11 +++++++++++ evap/settings/default.py | 1 + evap/settings/dev.py | 4 +++- flake.nix | 6 +++--- nix/services.nix | 8 ++++---- nix/shell.nix | 2 ++ 8 files changed, 25 insertions(+), 11 deletions(-) create mode 100644 evap/development/localsettings.template.py diff --git a/.gitignore b/.gitignore index 78ecec9641..45acb5a59d 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ dist/ .DS_Store thumbs.db +localsettings.py evap/localsettings.py evap/static/css/evap.css evap/static/css/evap.css.map diff --git a/evap/__main__.py b/evap/__main__.py index 3331006007..b5ee0af3e4 100755 --- a/evap/__main__.py +++ b/evap/__main__.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 import logging -import os import sys from django.conf import settings @@ -9,8 +8,6 @@ def main(): - assert not settings.configured - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "evap.settings") settings.DATADIR.mkdir(exist_ok=True) if settings.TESTING: diff --git a/evap/development/localsettings.template.py b/evap/development/localsettings.template.py new file mode 100644 index 0000000000..44138aa8fd --- /dev/null +++ b/evap/development/localsettings.template.py @@ -0,0 +1,11 @@ +from evap.settings import default, dev, local_services, open_id, test +from evap.settings_resolver import resolve_settings + + +class LocalSettings: + DEBUG = True + SECRET_KEY = "$SECRET_KEY" # nosec + ACTIVATE_OPEN_ID_LOGIN = False + + +globals().update(resolve_settings([default, dev, local_services, open_id, test, LocalSettings])) diff --git a/evap/settings/default.py b/evap/settings/default.py index e0f470c4cf..af21d5bc7c 100644 --- a/evap/settings/default.py +++ b/evap/settings/default.py @@ -28,6 +28,7 @@ def DATADIR(prev, final): ### Debugging DEBUG = required() +ENABLE_DEBUG_TOOLBAR = False ### EvaP logic diff --git a/evap/settings/dev.py b/evap/settings/dev.py index a5b0698273..0320cc1ccf 100644 --- a/evap/settings/dev.py +++ b/evap/settings/dev.py @@ -1,7 +1,9 @@ from fractions import Fraction -from evap.settings_resolver import derived + from django.utils.safestring import mark_safe +from evap.settings_resolver import derived + PAGE_URL = "localhost:8000" ACTIVATE_OPEN_ID_LOGIN = False diff --git a/flake.nix b/flake.nix index 5bd7fd2dbb..531339c293 100644 --- a/flake.nix +++ b/flake.nix @@ -75,7 +75,7 @@ pkgs = pkgsFor.${system}; pc-modules = import ./nix/services.nix { inherit pkgs; - inherit (self.devShells.${system}.evap.passthru) venv; + inherit (self.devShells.${system}.evap-dev.passthru) venv; }; make-process-compose = with-devenv-setup: (import inputs.process-compose-flake.lib { inherit pkgs; }).makeProcessCompose { modules = [ @@ -130,9 +130,9 @@ name = "clean-setup"; runtimeInputs = with pkgs; [ git ]; text = '' - read -r -p "Delete node_modules/, data/, generated CSS and JS files in evap/static/, and evap/localsettings.py? [y/N] " + read -r -p "Delete node_modules/, data/, generated CSS and JS files in evap/static/, and localsettings.py? [y/N] " [[ "$REPLY" =~ ^[Yy]$ ]] || exit 1 - git clean -f -X evap/static/ node_modules/ data/ evap/localsettings.py + git clean -f -X evap/static/ node_modules/ data/ localsettings.py ''; }; }); diff --git a/nix/services.nix b/nix/services.nix index 83df7f698b..cdb29b30d4 100644 --- a/nix/services.nix +++ b/nix/services.nix @@ -58,14 +58,14 @@ runtimeInputs = with pkgs; [ venv git gnused gettext coreutils ]; text = '' set -e - if [[ -f evap/localsettings.py ]]; then - echo "Found evap/localsettings.py, exiting." + if [[ -f localsettings.py ]]; then + echo "Found localsettings.py, exiting." echo "If you want to install a fresh environment, run clean-setup in a nix develop shell." exit 0 fi set -x - cp evap/development/localsettings.template.py evap/localsettings.py - sed -i -e "s/\$SECRET_KEY/$(head /dev/urandom | LC_ALL=C tr -dc A-Za-z0-9 | head -c 32)/" evap/localsettings.py + cp evap/development/localsettings.template.py localsettings.py + sed -i -e "s/\$SECRET_KEY/$(head /dev/urandom | LC_ALL=C tr -dc A-Za-z0-9 | head -c 32)/" localsettings.py git submodule update --init ./manage.py compilemessages --locale de ./manage.py reload_testdata --noinput diff --git a/nix/shell.nix b/nix/shell.nix index b832d9140b..2d45117e79 100644 --- a/nix/shell.nix +++ b/nix/shell.nix @@ -58,6 +58,8 @@ pkgs.mkShell { passthru = { inherit venv; }; env = { + DJANGO_SETTINGS_MODULE = "localsettings"; # this file is set up by services-full + PUPPETEER_SKIP_DOWNLOAD = 1; UV_NO_SYNC = "1"; UV_PYTHON = python3.interpreter; UV_PYTHON_DOWNLOADS = "never"; From 91d7c31f4db05e08c1b26d77ceb6ada7e324a7f0 Mon Sep 17 00:00:00 2001 From: Niklas Mohrin Date: Mon, 22 Sep 2025 20:00:25 +0200 Subject: [PATCH 06/22] cisettings --- .github/setup_evap/action.yml | 4 +--- .github/workflows/tests.yml | 5 +---- evap/development/cisettings.py | 10 ++++++++++ 3 files changed, 12 insertions(+), 7 deletions(-) create mode 100644 evap/development/cisettings.py diff --git a/.github/setup_evap/action.yml b/.github/setup_evap/action.yml index a5e91d3d26..4635a3ede8 100644 --- a/.github/setup_evap/action.yml +++ b/.github/setup_evap/action.yml @@ -23,9 +23,7 @@ runs: with: arguments: "${{ inputs.shell }}" - - name: Add localsettings - run: cp evap/settings_test.py evap/localsettings.py - shell: bash + - run: echo "DJANGO_SETTINGS_MODULE=evap.development.cisettings" >> "$GITHUB_ENV" - name: Create data/ directory run: mkdir -p data/ diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 55da9b7b6d..2e5f3d2051 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -225,14 +225,13 @@ jobs: WHEEL=$(echo *.whl) pip install $WHEEL[psycopg-binary] + - run: echo "DJANGO_SETTINGS_MODULE=evap.development.cisettings" >> "$GITHUB_ENV" - name: Load test data working-directory: deployment run: | cat <(echo 'from evap.settings import *') ../main/evap/settings_test.py | tee deployment_settings.py python -m evap migrate python -m evap loaddata test_data - env: - DJANGO_SETTINGS_MODULE: deployment_settings - name: Backup database working-directory: deployment run: ./update_production.sh backup.json @@ -240,13 +239,11 @@ jobs: EVAP_OVERRIDE_BACKUP_FILENAME: true EVAP_SKIP_UPDATE: true EVAP_SKIP_APACHE_STEPS: true - DJANGO_SETTINGS_MODULE: deployment_settings - name: Reload backup working-directory: deployment run: echo "yy" | ./load_production_backup.sh backup.json env: EVAP_SKIP_APACHE_STEPS: true - DJANGO_SETTINGS_MODULE: deployment_settings macos-nix-build: runs-on: macos-14 diff --git a/evap/development/cisettings.py b/evap/development/cisettings.py new file mode 100644 index 0000000000..a24c19eed7 --- /dev/null +++ b/evap/development/cisettings.py @@ -0,0 +1,10 @@ +from evap.settings import default, dev, local_services, open_id +from evap.settings_resolver import resolve_settings + + +class CiSettings: + DEBUG = True + SECRET_KEY = "github-actions-evap" # nosec + + +globals().update(resolve_settings([default, open_id, dev, local_services, CiSettings])) From 42ec4f486253b16d2b84f230cc2f109aaf639b3e Mon Sep 17 00:00:00 2001 From: Niklas Mohrin Date: Mon, 22 Sep 2025 20:01:24 +0200 Subject: [PATCH 07/22] modules --- evap/development/cisettings.py | 4 ++-- evap/development/localsettings.template.py | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/evap/development/cisettings.py b/evap/development/cisettings.py index a24c19eed7..7d8082fe41 100644 --- a/evap/development/cisettings.py +++ b/evap/development/cisettings.py @@ -1,4 +1,4 @@ -from evap.settings import default, dev, local_services, open_id +from evap.settings import default, dev, local_services, open_id, test from evap.settings_resolver import resolve_settings @@ -7,4 +7,4 @@ class CiSettings: SECRET_KEY = "github-actions-evap" # nosec -globals().update(resolve_settings([default, open_id, dev, local_services, CiSettings])) +globals().update(resolve_settings([default, open_id, dev, local_services, test, CiSettings])) diff --git a/evap/development/localsettings.template.py b/evap/development/localsettings.template.py index 44138aa8fd..c17c30f210 100644 --- a/evap/development/localsettings.template.py +++ b/evap/development/localsettings.template.py @@ -5,7 +5,6 @@ class LocalSettings: DEBUG = True SECRET_KEY = "$SECRET_KEY" # nosec - ACTIVATE_OPEN_ID_LOGIN = False -globals().update(resolve_settings([default, dev, local_services, open_id, test, LocalSettings])) +globals().update(resolve_settings([default, local_services, open_id, dev, test, LocalSettings])) From f77ac7d3fce6e4f955ed84ee77329e70fed76a46 Mon Sep 17 00:00:00 2001 From: Niklas Mohrin Date: Mon, 22 Sep 2025 20:05:15 +0200 Subject: [PATCH 08/22] p --- nix/shell.nix | 1 - 1 file changed, 1 deletion(-) diff --git a/nix/shell.nix b/nix/shell.nix index 2d45117e79..d361a86c2e 100644 --- a/nix/shell.nix +++ b/nix/shell.nix @@ -59,7 +59,6 @@ pkgs.mkShell { env = { DJANGO_SETTINGS_MODULE = "localsettings"; # this file is set up by services-full - PUPPETEER_SKIP_DOWNLOAD = 1; UV_NO_SYNC = "1"; UV_PYTHON = python3.interpreter; UV_PYTHON_DOWNLOADS = "never"; From 8cafe75610646a1cec16fbc4dca82300476cfcd7 Mon Sep 17 00:00:00 2001 From: Niklas Mohrin Date: Mon, 22 Sep 2025 20:07:45 +0200 Subject: [PATCH 09/22] sure --- .github/setup_evap/action.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/setup_evap/action.yml b/.github/setup_evap/action.yml index 4635a3ede8..f8628eec04 100644 --- a/.github/setup_evap/action.yml +++ b/.github/setup_evap/action.yml @@ -24,6 +24,7 @@ runs: arguments: "${{ inputs.shell }}" - run: echo "DJANGO_SETTINGS_MODULE=evap.development.cisettings" >> "$GITHUB_ENV" + shell: bash - name: Create data/ directory run: mkdir -p data/ From b4d1bdfb3991e6ae6775277ce3ab115ec1479e10 Mon Sep 17 00:00:00 2001 From: Niklas Mohrin Date: Mon, 22 Sep 2025 21:04:14 +0200 Subject: [PATCH 10/22] github --- .github/workflows/tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2e5f3d2051..4fa952ec19 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -69,6 +69,7 @@ jobs: with: submodules: true - uses: DeterminateSystems/nix-installer-action@main + - run: echo "DJANGO_SETTINGS_MODULE=evap.development.cisettings" >> "$GITHUB_ENV" - run: nix run .#build-dist - run: tar tvf dist/*.tar.gz - run: unzip -l dist/*.whl From d2f86be7710e6c2984dd5e932c62c705ae333ac8 Mon Sep 17 00:00:00 2001 From: Niklas Mohrin Date: Mon, 22 Sep 2025 22:19:00 +0200 Subject: [PATCH 11/22] add schema to fix mypy --- .github/setup_evap/action.yml | 5 ++++- .gitignore | 1 + evap/settings/schema.pyi | 37 +++++++++++++++++++++++++++++++++++ flake.nix | 4 ++-- nix/services.nix | 1 + pyproject.toml | 1 - 6 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 evap/settings/schema.pyi diff --git a/.github/setup_evap/action.yml b/.github/setup_evap/action.yml index f8628eec04..10f2f3f8b6 100644 --- a/.github/setup_evap/action.yml +++ b/.github/setup_evap/action.yml @@ -23,7 +23,10 @@ runs: with: arguments: "${{ inputs.shell }}" - - run: echo "DJANGO_SETTINGS_MODULE=evap.development.cisettings" >> "$GITHUB_ENV" + - run: | + echo "DJANGO_SETTINGS_MODULE=localsettings" >> "$GITHUB_ENV" + ln -s evap/development/cisettings.py localsettings.py + ln -s evap/settings/schema.pyi localsettings.pyi shell: bash - name: Create data/ directory diff --git a/.gitignore b/.gitignore index 45acb5a59d..f14eb3d615 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ dist/ thumbs.db localsettings.py +localsettings.pyi evap/localsettings.py evap/static/css/evap.css evap/static/css/evap.css.map diff --git a/evap/settings/schema.pyi b/evap/settings/schema.pyi new file mode 100644 index 0000000000..f90bee94f8 --- /dev/null +++ b/evap/settings/schema.pyi @@ -0,0 +1,37 @@ +from datetime import timedelta +from fractions import Fraction +from pathlib import Path +from typing import Any + +ACTIVATE_OPEN_ID_LOGIN: bool +ADMINS: list[tuple[str, str]] +CONTACT_EMAIL: str +DATADIR: Path +ENABLE_DEBUG_TOOLBAR: bool +EVALUATION_END_OFFSET_HOURS: int +EXAM_QUESTIONNAIRE_IDS: list[int] +GLOBAL_EVALUATION_PROGRESS_EXCLUDED_COURSE_TYPE_IDS: list[int] +GLOBAL_EVALUATION_PROGRESS_EXCLUDED_EVALUATION_IDS: list[int] +GLOBAL_EVALUATION_PROGRESS_INFO_TEXT: dict[str, str] +GLOBAL_EVALUATION_PROGRESS_REWARDS: list[tuple[Fraction, str]] +GRADE_REMINDER_EMAIL_RECIPIENTS: list[str] +IGNORE_PROGRAMS: set[str] +IMPORTER_COURSE_NAME_SIMILARITY_WARNING_THRESHOLD: float +IMPORTER_GRADED_NO: list[str] +IMPORTER_GRADED_YES: list[str] +IMPORTER_MAX_ENROLLMENTS: int +INSTITUTION_EMAIL_DOMAINS: list[str] +INSTITUTION_EMAIL_REPLACEMENTS: list[tuple[str, str]] +LEGAL_NOTICE_TEXT: str +MEDIA_ROOT: Path +MODULE: Path +NON_RESPONSIBLE_USERS: set[str] +OIDC_EMAIL_TRANSITIONS: dict[str, str] +PAGE_URL: str +PARTICIPATION_DELETION_AFTER_INACTIVE_TIME: timedelta +REPLY_TO_EMAIL: str +REWARD_POINTS: list[tuple[float, int]] +SEND_ALL_EMAILS_TO_ADMINS_IN_BCC: bool +SMALL_COURSE_SIZE: int +TEMPLATES: Any +VOTER_COUNT_NEEDED_FOR_PUBLISHING_RATING_RESULTS: int diff --git a/flake.nix b/flake.nix index 531339c293..4127793eb8 100644 --- a/flake.nix +++ b/flake.nix @@ -130,9 +130,9 @@ name = "clean-setup"; runtimeInputs = with pkgs; [ git ]; text = '' - read -r -p "Delete node_modules/, data/, generated CSS and JS files in evap/static/, and localsettings.py? [y/N] " + read -r -p "Delete node_modules/, data/, generated CSS and JS files in evap/static/, and localsettings? [y/N] " [[ "$REPLY" =~ ^[Yy]$ ]] || exit 1 - git clean -f -X evap/static/ node_modules/ data/ localsettings.py + git clean -f -X evap/static/ node_modules/ data/ localsettings.py localsettings.pyi ''; }; }); diff --git a/nix/services.nix b/nix/services.nix index cdb29b30d4..f1fa4eeb56 100644 --- a/nix/services.nix +++ b/nix/services.nix @@ -65,6 +65,7 @@ fi set -x cp evap/development/localsettings.template.py localsettings.py + ln -s evap/settings/schema.pyi localsettings.pyi sed -i -e "s/\$SECRET_KEY/$(head /dev/urandom | LC_ALL=C tr -dc A-Za-z0-9 | head -c 32)/" localsettings.py git submodule update --init ./manage.py compilemessages --locale de diff --git a/pyproject.toml b/pyproject.toml index 9eb115c72d..c19f514dcd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -199,7 +199,6 @@ packages = ["evap", "tools"] plugins = ["mypy_django_plugin.main"] [tool.django-stubs] -django_settings_module = "evap.settings" [[tool.mypy.overrides]] module = [ From 246eaf2aed20c90d41da9ec9b97ff1a6d63e7913 Mon Sep 17 00:00:00 2001 From: Niklas Mohrin Date: Mon, 22 Sep 2025 22:25:52 +0200 Subject: [PATCH 12/22] ci --- .github/workflows/tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4fa952ec19..88372396ff 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -93,6 +93,8 @@ jobs: - run: pip install *.whl - name: Check that "evaluation" section appears in help string run: python -m evap --help | grep --fixed-strings "[evaluation]" + env: + DJANGO_SETTINGS_MODULE: evap.development.cisettings test_frontend: name: Test Frontend From 665f3bf8fc513134142a02e44a33883bfae0f8b4 Mon Sep 17 00:00:00 2001 From: Niklas Mohrin Date: Tue, 23 Sep 2025 16:25:38 +0200 Subject: [PATCH 13/22] test --- evap/settings/test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evap/settings/test.py b/evap/settings/test.py index cef5843d8a..205d6765eb 100644 --- a/evap/settings/test.py +++ b/evap/settings/test.py @@ -1,8 +1,6 @@ import sys from copy import deepcopy -from model_bakery import random_gen - from evap.settings_resolver import derived TEST_RUNNER = "evap.evaluation.tests.tools.EvapTestRunner" @@ -44,6 +42,8 @@ def CACHES(prev, final): @derived(prev={"BAKER_CUSTOM_FIELDS_GEN"}, final={"TESTING"}) def BAKER_CUSTOM_FIELDS_GEN(prev, final): if final.TESTING: + from model_bakery import random_gen + # give random char field values a reasonable length return {"django.db.models.CharField": lambda: random_gen.gen_string(20)} return prev.BAKER_CUSTOM_FIELDS_GEN From e19080d32b6583589939821b57ea05aaf01cfd98 Mon Sep 17 00:00:00 2001 From: Niklas Mohrin Date: Tue, 23 Sep 2025 16:34:44 +0200 Subject: [PATCH 14/22] remove django settings module config for linters, becasue they also take it from the env var --- pyproject.toml | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c19f514dcd..91ad527597 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -129,7 +129,6 @@ exclude = ["**/urls.py"] jobs = 0 load-plugins = ["pylint_django"] -django-settings-module = "evap.settings" [tool.pylint.basic] # For most code: snake_case, or PascalCaseFormset, because django does it that way. @@ -221,7 +220,6 @@ ignore_missing_imports = true [tool.pytest.ini_options] # We don't officially use pytest, but last time we wanted to, this worked for us with pytest-django # pytest-xdist worked for parallelizing tests. -DJANGO_SETTINGS_MODULE = "evap.settings" python_files = ["tests.py", "test_*.py", "*_tests.py"] testpaths = ["evap", "tools"] norecursedirs=["locale", "logs", "static", "static_collected", "upload"] From 239c42ea7ce4158f57563a68d0514a76a5bf6073 Mon Sep 17 00:00:00 2001 From: Niklas Mohrin Date: Tue, 23 Sep 2025 16:34:52 +0200 Subject: [PATCH 15/22] fix wrong module path --- evap/settings/default.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evap/settings/default.py b/evap/settings/default.py index af21d5bc7c..a7a56b3805 100644 --- a/evap/settings/default.py +++ b/evap/settings/default.py @@ -163,7 +163,7 @@ def DATADIR(prev, final): "BACKEND": "django.core.files.storage.FileSystemStorage", }, "staticfiles": { - "BACKEND": "evap.settings.common.ManifestStaticFilesStorageWithJsReplacement", + "BACKEND": "evap.settings.default.ManifestStaticFilesStorageWithJsReplacement", }, } From f6fdd4bf47764a149c4e4742ed8a7d135cd0636f Mon Sep 17 00:00:00 2001 From: Niklas Mohrin Date: Tue, 23 Sep 2025 17:10:50 +0200 Subject: [PATCH 16/22] tests --- evap/evaluation/tests/test_tools.py | 64 +++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/evap/evaluation/tests/test_tools.py b/evap/evaluation/tests/test_tools.py index 7545a62f3e..45c6b7747a 100644 --- a/evap/evaluation/tests/test_tools.py +++ b/evap/evaluation/tests/test_tools.py @@ -1,3 +1,5 @@ +import graphlib +from argparse import Namespace from unittest.mock import patch from uuid import UUID @@ -18,6 +20,7 @@ inside_transaction, is_prefetched, ) +from evap.settings_resolver import derived, not_set, required, resolve_settings class TestLanguageMiddleware(WebTest): @@ -242,3 +245,64 @@ def test_inside_transaction(self): with transaction.atomic(): self.assertTrue(inside_transaction()) + + +class TestResolveSettings(TestCase): + def test_not_set(self): + self.assertEqual(resolve_settings([Namespace(FOO=42), Namespace(FOO=not_set())]), {}) + self.assertEqual(resolve_settings([Namespace(FOO=not_set()), Namespace(FOO=42)]), {"FOO": 42}) + + def test_key_names(self): + self.assertEqual(resolve_settings([Namespace(foo=1, _FOO=2, FoO=3)]), {}) + + def test_derived(self): + self.assertEqual( + resolve_settings( + [ + Namespace( + FOO=1, + BAR=derived(final={"FOO"})(lambda prev, final: final.FOO + 10), + ), + Namespace( + FOO=derived(prev={"FOO"})(lambda prev, final: prev.FOO + 100), + BAR=derived(prev={"BAR"})(lambda prev, final: prev.BAR + 1000), + ), + ] + ), + { + "FOO": 101, + "BAR": 1111, + }, + ) + + def test_cycle(self): + with self.assertRaises(graphlib.CycleError): + resolve_settings( + [ + Namespace( + FOO=derived(final={"BAR"})(lambda prev, final: final.BAR), + BAR=derived(final={"FOO"})(lambda prev, final: final.FOO), + ), + Namespace( + FOO=derived(prev={"FOO"})(lambda prev, final: prev.FOO), + BAR=derived(prev={"BAR"})(lambda prev, final: prev.BAR), + ), + ] + ) + + def test_required(self): + with self.assertRaises(ValueError): + resolve_settings([Namespace(FOO=required())]) + with self.assertRaises(ValueError): + resolve_settings( + [Namespace(FOO=required()), Namespace(FOO=42, BAR=derived(prev={"FOO"})(lambda prev, final: prev.FOO))] + ) + self.assertEqual( + resolve_settings( + [ + Namespace(FOO=required()), + Namespace(FOO=42, BAR=derived(final={"FOO"})(lambda prev, final: final.FOO)), + ] + ), + {"FOO": 42, "BAR": 42}, + ) From 63aa611cd85f0db94beff326376d401536889de4 Mon Sep 17 00:00:00 2001 From: Niklas Mohrin Date: Tue, 23 Sep 2025 17:26:41 +0200 Subject: [PATCH 17/22] disable lints --- evap/__main__.py | 2 +- evap/development/cisettings.py | 2 +- evap/development/localsettings.template.py | 4 +++- evap/settings/debug_toolbar.py | 2 ++ evap/settings/default.py | 1 + evap/settings/dev.py | 2 ++ evap/settings/local_services.py | 2 ++ evap/settings/test.py | 4 +++- 8 files changed, 15 insertions(+), 4 deletions(-) diff --git a/evap/__main__.py b/evap/__main__.py index b5ee0af3e4..1083eb6c7f 100755 --- a/evap/__main__.py +++ b/evap/__main__.py @@ -11,7 +11,7 @@ def main(): settings.DATADIR.mkdir(exist_ok=True) if settings.TESTING: - from typeguard import install_import_hook # pylint: disable=import-outside-toplevel + from typeguard import install_import_hook # noqa: PLC0415 install_import_hook(("evap", "tools")) logging.disable() diff --git a/evap/development/cisettings.py b/evap/development/cisettings.py index 7d8082fe41..76e27eee59 100644 --- a/evap/development/cisettings.py +++ b/evap/development/cisettings.py @@ -1,4 +1,4 @@ -from evap.settings import default, dev, local_services, open_id, test +from evap.settings import default, dev, local_services, open_id, test # noqa: TID251 from evap.settings_resolver import resolve_settings diff --git a/evap/development/localsettings.template.py b/evap/development/localsettings.template.py index c17c30f210..f2247e4679 100644 --- a/evap/development/localsettings.template.py +++ b/evap/development/localsettings.template.py @@ -1,4 +1,6 @@ -from evap.settings import default, dev, local_services, open_id, test +# noqa: N999 + +from evap.settings import default, dev, local_services, open_id, test # noqa: TID251 from evap.settings_resolver import resolve_settings diff --git a/evap/settings/debug_toolbar.py b/evap/settings/debug_toolbar.py index b52d06b67b..cc0618a66a 100644 --- a/evap/settings/debug_toolbar.py +++ b/evap/settings/debug_toolbar.py @@ -1,3 +1,5 @@ +# pylint: disable=unused-argument,invalid-name + from evap.settings_resolver import derived # Very helpful but eats a lot of performance on sql-heavy pages. diff --git a/evap/settings/default.py b/evap/settings/default.py index a7a56b3805..d7b3ca6031 100644 --- a/evap/settings/default.py +++ b/evap/settings/default.py @@ -1,4 +1,5 @@ # ruff: noqa: E731, N803 +# pylint: disable=unused-argument,invalid-name from datetime import timedelta from fractions import Fraction diff --git a/evap/settings/dev.py b/evap/settings/dev.py index 0320cc1ccf..c57f4e75c3 100644 --- a/evap/settings/dev.py +++ b/evap/settings/dev.py @@ -1,3 +1,5 @@ +# pylint: disable=invalid-name + from fractions import Fraction from django.utils.safestring import mark_safe diff --git a/evap/settings/local_services.py b/evap/settings/local_services.py index 548f7a058a..4cf5b189bf 100644 --- a/evap/settings/local_services.py +++ b/evap/settings/local_services.py @@ -1,3 +1,5 @@ +# pylint: disable=unused-argument,invalid-name + from evap.settings_resolver import derived diff --git a/evap/settings/test.py b/evap/settings/test.py index 205d6765eb..cf9657baff 100644 --- a/evap/settings/test.py +++ b/evap/settings/test.py @@ -1,3 +1,5 @@ +# pylint: disable=invalid-name + import sys from copy import deepcopy @@ -42,7 +44,7 @@ def CACHES(prev, final): @derived(prev={"BAKER_CUSTOM_FIELDS_GEN"}, final={"TESTING"}) def BAKER_CUSTOM_FIELDS_GEN(prev, final): if final.TESTING: - from model_bakery import random_gen + from model_bakery import random_gen # noqa: PLC0415 # give random char field values a reasonable length return {"django.db.models.CharField": lambda: random_gen.gen_string(20)} From d5d70dd87ff450767724bf8630eae493cf6093c0 Mon Sep 17 00:00:00 2001 From: Niklas Mohrin Date: Tue, 23 Sep 2025 17:40:13 +0200 Subject: [PATCH 18/22] use old secret to make diff smaller --- evap/development/cisettings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evap/development/cisettings.py b/evap/development/cisettings.py index 76e27eee59..c24fabb12b 100644 --- a/evap/development/cisettings.py +++ b/evap/development/cisettings.py @@ -4,7 +4,7 @@ class CiSettings: DEBUG = True - SECRET_KEY = "github-actions-evap" # nosec + SECRET_KEY = "evap-github-actions-secret-key" # nosec globals().update(resolve_settings([default, open_id, dev, local_services, test, CiSettings])) From 358fe2ae4abd7f57391cd506382f5214474e7b78 Mon Sep 17 00:00:00 2001 From: Niklas Mohrin Date: Tue, 23 Sep 2025 17:55:41 +0200 Subject: [PATCH 19/22] include debug_toolbar settings in localsettings template --- evap/development/localsettings.template.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evap/development/localsettings.template.py b/evap/development/localsettings.template.py index f2247e4679..0610d2bdb1 100644 --- a/evap/development/localsettings.template.py +++ b/evap/development/localsettings.template.py @@ -1,6 +1,6 @@ # noqa: N999 -from evap.settings import default, dev, local_services, open_id, test # noqa: TID251 +from evap.settings import debug_toolbar, default, dev, local_services, open_id, test # noqa: TID251 from evap.settings_resolver import resolve_settings @@ -9,4 +9,4 @@ class LocalSettings: SECRET_KEY = "$SECRET_KEY" # nosec -globals().update(resolve_settings([default, local_services, open_id, dev, test, LocalSettings])) +globals().update(resolve_settings([default, local_services, open_id, dev, test, debug_toolbar, LocalSettings])) From 5b7a76eacd647478bf43e71375a8c4481d4f0b91 Mon Sep 17 00:00:00 2001 From: Niklas Mohrin Date: Tue, 23 Sep 2025 18:12:45 +0200 Subject: [PATCH 20/22] fix final index bug (maybe I should just rewrite the final indices in the beginning?) --- evap/evaluation/tests/test_tools.py | 17 +++++++++++++++++ evap/settings_resolver.py | 24 +++++++++++++----------- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/evap/evaluation/tests/test_tools.py b/evap/evaluation/tests/test_tools.py index 45c6b7747a..7674fd399b 100644 --- a/evap/evaluation/tests/test_tools.py +++ b/evap/evaluation/tests/test_tools.py @@ -306,3 +306,20 @@ def test_required(self): ), {"FOO": 42, "BAR": 42}, ) + + def test_derived_final_dependencies(self): + # Ensure that derived final dependencies are ordered before in the topological order + self.assertEqual( + resolve_settings( + [ + Namespace(FOO=required()), + Namespace( + FOO=42, + BAR=derived(final={"BAZ"})(lambda prev, final: final.BAZ), + BAZ=derived(final={"FOO"})(lambda prev, final: final.FOO), + ), + Namespace(), + ] + ), + {"FOO": 42, "BAR": 42, "BAZ": 42}, + ) diff --git a/evap/settings_resolver.py b/evap/settings_resolver.py index 67eaa12a42..c87481b3d3 100644 --- a/evap/settings_resolver.py +++ b/evap/settings_resolver.py @@ -58,13 +58,11 @@ def final_layer(self) -> int: def all_setting_names(self) -> Iterable[str]: return {name for layer in self.layers for name in layer} - def get_setting_at_layer(self, name, layer_index) -> T | Required | NotSet: + def get_setting_at_layer(self, name, layer_index) -> tuple[int, T | Derived | Required | NotSet]: for i in range(layer_index, 0, -1): if name in self.layers[i]: - value = self.layers[i][name] - assert not isinstance(value, Derived) - return value - return NotSet.NOT_SET + return i, self.layers[i][name] + return 0, NotSet.NOT_SET def compute_derived_values(self) -> None: sorter = TopologicalSorter[tuple[int, str]]() @@ -78,17 +76,21 @@ def compute_derived_values(self) -> None: sorter.add((i, name), *deps) for index, name in sorter.static_order(): - match self.layers[index].get(name): - case Derived(fn, prev, final): - prev_ns = Namespace(**{name: self.get_setting_at_layer(name, index - 1) for name in prev}) - final_ns = Namespace(**{name: self.get_setting_at_layer(name, self.final_layer) for name in final}) - self.layers[index][name] = fn(prev_ns, final_ns) + match self.get_setting_at_layer(name, index): + case actual_index, Derived(fn, prev, final): + prev_ns = Namespace(**{name: self.get_setting_at_layer(name, actual_index - 1)[1] for name in prev}) + final_ns = Namespace( + **{name: self.get_setting_at_layer(name, self.final_layer)[1] for name in final} + ) + assert all(not isinstance(val, Derived) for val in vars(prev_ns).values()) + assert all(not isinstance(val, Derived) for val in vars(final_ns).values()) + self.layers[actual_index][name] = fn(prev=prev_ns, final=final_ns) def get_final_values(self, keys: Iterable[str]) -> dict[str, T]: resolved = {} missing_settings = set() for name in keys: - value = self.get_setting_at_layer(name, self.final_layer) + _, value = self.get_setting_at_layer(name, self.final_layer) match value: case Derived(): raise AssertionError("derived values should have been resolved by now") From 86c07f8c089585be76a21208413dbdc4691483cb Mon Sep 17 00:00:00 2001 From: Niklas Mohrin Date: Mon, 27 Oct 2025 16:37:47 +0100 Subject: [PATCH 21/22] comments --- evap/settings_resolver.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/evap/settings_resolver.py b/evap/settings_resolver.py index c87481b3d3..74e3561fec 100644 --- a/evap/settings_resolver.py +++ b/evap/settings_resolver.py @@ -12,32 +12,70 @@ @dataclass class Derived: + """A setting that is derived from other settings.""" + fn: Callable prev: set[str] final: set[str] def derived(*, prev: set[str] | None = None, final: set[str] | None = None) -> Callable[[Callable], Derived]: + """Decorator to create derived settings.""" return partial(Derived, prev=prev or set(), final=final or set()) class Required(Enum): + """Type for marking settings as required. + + The resolver throws an error if a setting is required in the final mapping of settings. Thus, a setting declared as + required must be overwritten in a later layer.""" REQUIRED = auto() def required() -> Required: + """Shorthand function to create a required setting.""" return Required.REQUIRED class NotSet(Enum): + """Type for marking settings as not set, that is, for keeping Django's default value.""" NOT_SET = auto() def not_set() -> NotSet: + """Shorthand function to create a not-set setting.""" return NotSet.NOT_SET class SettingResolver(Generic[T]): + """Main class for setting resolution. + + By default, Django settings are configured in a module like + ```python + DEBUG = True + DATADIR = Path("./data") + STATIC_ROOT = DATADIR / "static_collected" + ``` + + This interface works for simple setups, but lacks some features that we desire. Most importantly, we want to declare + a set of default settings that do not necessarily need to be changed when deploying EvaP. Some of these settings + depend on other settings though, for example the STATIC_ROOT setting above depends on the DATADIR setting. If users + would change the value of DATADIR, they would also have to reassign STATIC_ROOT accordingly. + + To resolve this issue, we provide the SettingResolver which takes setting modules and aggregates them into a final + mapping of settings to pass to Django. Notably, the input modules to the resolver can explicitly declare + dependencies between settings using the `@derived` decorator. Concretely, the settings are organized into different + layers. Later layers overwrite previous settings. A derived setting can then declare a dependency on another setting + value from either the previous layer (for example to append an entry to a list) or the final layer (for example to + use a path overwritten by the user). + + To compute the final set of setting values, we form a dependency graph on the settings and layers and compute the + according values in topological order. + + Note that the SettingResolver class is generic over the type of the setting values T, however we only use it below + with T = Any. The type T is only used to track at what times values can still be derived. + """ + @staticmethod def iter_settings(namespace: Any) -> Iterable[str]: for name in dir(namespace): From 2a4670690ceb38ccfc2764d8d2ebb0fbe8405870 Mon Sep 17 00:00:00 2001 From: Niklas Mohrin Date: Mon, 10 Nov 2025 22:37:52 +0100 Subject: [PATCH 22/22] more comments --- evap/evaluation/tests/test_tools.py | 2 ++ evap/settings_resolver.py | 1 + 2 files changed, 3 insertions(+) diff --git a/evap/evaluation/tests/test_tools.py b/evap/evaluation/tests/test_tools.py index 7674fd399b..69e059e88a 100644 --- a/evap/evaluation/tests/test_tools.py +++ b/evap/evaluation/tests/test_tools.py @@ -249,10 +249,12 @@ def test_inside_transaction(self): class TestResolveSettings(TestCase): def test_not_set(self): + # We don't use the "remove a previous setting" functionality anywhere, but this behavior is the natural composition of layers and not_set self.assertEqual(resolve_settings([Namespace(FOO=42), Namespace(FOO=not_set())]), {}) self.assertEqual(resolve_settings([Namespace(FOO=not_set()), Namespace(FOO=42)]), {"FOO": 42}) def test_key_names(self): + # See SettingResolver.iter_settings self.assertEqual(resolve_settings([Namespace(foo=1, _FOO=2, FoO=3)]), {}) def test_derived(self): diff --git a/evap/settings_resolver.py b/evap/settings_resolver.py index 74e3561fec..22eef776cb 100644 --- a/evap/settings_resolver.py +++ b/evap/settings_resolver.py @@ -78,6 +78,7 @@ class SettingResolver(Generic[T]): @staticmethod def iter_settings(namespace: Any) -> Iterable[str]: + """Filter for attributes with SCREAMING_SNAKE_CASE names.""" for name in dir(namespace): if name == name.upper() and not name.startswith("_"): yield name