Repository: e-valuation/EvaP

Issue:
Update to django 4.1
(#1794 is currently blocked by a bug that is fixed in django 4.1)

[Same procedure as every year](https://github.com/e-valuation/EvaP/issues/1387)

Some points I think might be relevant
* ~Django enables the cached template loader by default now. I think we want to be able to edit a template, reload the page, and instantly see the changes reflected with a development setup. So, we probably want to set a custom, non-cached template loader when `DEBUG=True`?~ **Edit**: As pointed out by @karyon below, the cached template loader has some auto-reload functionality, should work for us.
* ~https://code.djangoproject.com/ticket/33631 was closed, so `blocktranslate asvar` now marks the output as safe. This should allow us to remove all `|safe` filter calls that we have left in our templates after https://github.com/e-valuation/EvaP/pull/1737.~ **Edit**: This will be in 4.2, not 4.1
* ~Django defaults to a new forms renderer using `div` tags. We should check if this breaks some layout because CSS rules do not apply anymore.~ **Edit**: As @karyon points out below, default will be changed in 5.0.


Discussion:
Regarding the first point, looking at https://code.djangoproject.com/ticket/33639, it seems that templates do auto-reload even when cached.
Regarding the third point, the default is changed only in 5.0.

Now that constraints are validated during model validation, [this Questionnaire validation error](https://github.com/e-valuation/EvaP/blob/main/evap/evaluation/models.py#L199) can probably be expressed as a constraint. That would give us additional safety at the db level.

Somewhat related, I noticed a couple asserts/checks that might be better expressed as constraints. I'm not sure there was a reason why we didn't do that.
* [start date must be before end date](https://github.com/e-valuation/EvaP/blob/main/evap/evaluation/models.py#L467)
* [text and heading questions do not allow additional textanswers](https://github.com/e-valuation/EvaP/blob/main/evap/evaluation/models.py#L1121)
* [TextAnswer's answer and original_answer must not be the same](https://github.com/e-valuation/EvaP/blob/main/evap/evaluation/models.py#L1461)
* [Evaluation's _voter_count must be set iff _participant_count is set](https://github.com/e-valuation/EvaP/blob/main/evap/evaluation/models.py#L620)
* [A bunch of validation when redeeming points](https://github.com/e-valuation/EvaP/blob/fc3f5b4390fbaf2aff2633dfbe61bbdd8e80c8b7/evap/rewards/tools.py#L36-L46)


Resolving patch:
diff --git a/evap/evaluation/models.py b/evap/evaluation/models.py
--- a/evap/evaluation/models.py
+++ b/evap/evaluation/models.py
@@ -778,6 +778,9 @@ def state_str(self):
 
     @cached_property
     def general_contribution(self):
+        if self.pk is None:
+            return None
+
         try:
             return self.contributions.get(contributor=None)
         except Contribution.DoesNotExist:
diff --git a/evap/rewards/tools.py b/evap/rewards/tools.py
--- a/evap/rewards/tools.py
+++ b/evap/rewards/tools.py
@@ -82,7 +82,7 @@ def grant_reward_points_if_eligible(user, semester):
         or 0
     )
     progress = float(required_evaluations.filter(voters=user).count()) / float(required_evaluations.count())
-    target_points = max([points for threshold, points in settings.REWARD_POINTS if threshold <= progress], default=0)
+    target_points = max((points for threshold, points in settings.REWARD_POINTS if threshold <= progress), default=0)
     missing_points = target_points - granted_points
 
     if missing_points > 0:
diff --git a/evap/staff/forms.py b/evap/staff/forms.py
--- a/evap/staff/forms.py
+++ b/evap/staff/forms.py
@@ -768,7 +768,8 @@ class ContributionFormset(BaseInlineFormSet):
     def __init__(self, data=None, **kwargs):
         data = self.handle_moved_contributors(data, **kwargs)
         super().__init__(data, **kwargs)
-        self.queryset = self.instance.contributions.exclude(contributor=None)
+        if self.instance.pk is not None:
+            self.queryset = self.instance.contributions.exclude(contributor=None)
 
     def handle_deleted_and_added_contributions(self):
         """


Test patch: