Repository: e-valuation/EvaP

Issue:
Log M2M Clear
With #1579 participants and contributors of an evaluation can be replaced. In the code, the participants/contributors are cleared first and then the new ones are added.
The log only lists the newly added participants/contributors and does not show an entry for the deleted ones.
The deleted participants/contributors should also be logged.


Discussion:
Also, when removing and adding participants in the evaluation form at the same time, removed participants are not shown in the log.

Resolving patch:
diff --git a/evap/evaluation/models_logging.py b/evap/evaluation/models_logging.py
--- a/evap/evaluation/models_logging.py
+++ b/evap/evaluation/models_logging.py
@@ -131,6 +131,7 @@ class Meta:
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self._logentry = None
+        self._m2m_changes = defaultdict(lambda: defaultdict(list))
 
     def _as_dict(self):
         """
@@ -182,8 +183,12 @@ def _get_change_data(self, action_type: InstanceActionType):
 
         return changes
 
-    def log_m2m_change(self, changes):
-        self._update_log(changes, InstanceActionType.CHANGE)
+    def log_m2m_change(self, field_name, action_type: FieldActionType, change_list):
+        # This might be called multiple times with cumulating changes
+        # But this is fine, since the old changes will be included in the latest log update
+        # See https://github.com/e-valuation/EvaP/issues/1594
+        self._m2m_changes[field_name][action_type] += change_list
+        self._update_log(self._m2m_changes, InstanceActionType.CHANGE)
 
     def log_instance_create(self):
         changes = self._get_change_data(InstanceActionType.CREATE)
@@ -304,13 +309,9 @@ def _m2m_changed(sender, instance, action, reverse, model, pk_set, **kwargs):  #
     if field_name in instance.unlogged_fields:
         return
 
-    m2m_changes = defaultdict(lambda: defaultdict(list))
     if action == "pre_remove":
-        m2m_changes[field_name][FieldActionType.M2M_REMOVE] += list(pk_set)
+        instance.log_m2m_change(field_name, FieldActionType.M2M_REMOVE, list(pk_set))
     elif action == "pre_add":
-        m2m_changes[field_name][FieldActionType.M2M_ADD] += list(pk_set)
+        instance.log_m2m_change(field_name, FieldActionType.M2M_ADD, list(pk_set))
     elif action == "pre_clear":
-        m2m_changes[field_name][FieldActionType.M2M_CLEAR] = []
-
-    if m2m_changes:
-        instance.log_m2m_change(m2m_changes)
+        instance.log_m2m_change(field_name, FieldActionType.M2M_CLEAR, [])


Test patch:
diff --git a/evap/evaluation/tests/test_models_logging.py b/evap/evaluation/tests/test_models_logging.py
--- a/evap/evaluation/tests/test_models_logging.py
+++ b/evap/evaluation/tests/test_models_logging.py
@@ -4,7 +4,7 @@
 from django.utils.formats import localize
 from model_bakery import baker
 
-from evap.evaluation.models import Evaluation, Course, Contribution, Questionnaire
+from evap.evaluation.models import Evaluation, Course, Contribution, Questionnaire, UserProfile
 from evap.evaluation.models_logging import FieldAction
 
 
@@ -82,3 +82,18 @@ def test_none_value_not_included(self):
 
         baker.make(Contribution, evaluation=self.evaluation, label=None)
         self.assertNotIn("label", self.evaluation.related_logentries().order_by("id").last().data)
+
+    def test_simultaneous_add_and_remove(self):
+        # Regression test for https://github.com/e-valuation/EvaP/issues/1594
+        participant1 = baker.make(UserProfile)
+        participant2 = baker.make(UserProfile)
+        self.evaluation.participants.add(participant1)
+        # Refresh reference to evaluation, to force new log entry
+        self.evaluation = Evaluation.objects.get(pk=self.evaluation.pk)
+
+        self.evaluation.participants.remove(participant1)
+        self.evaluation.participants.add(participant2)
+        self.assertEqual(
+            self.evaluation.related_logentries().order_by("id").last().data,
+            {"participants": {"add": [participant2.id], "remove": [participant1.id]}},
+        )