We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN 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.

--- END ISSUE ---

Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
--- BEGIN FILES ---
Path: `evap/evaluation/models_logging.py`
Content:
```
1 from collections import defaultdict, namedtuple
2 from datetime import date, datetime, time
3 from enum import Enum
4 import itertools
5 import threading
6 from json import JSONEncoder
7 
8 from django.conf import settings
9 from django.contrib.contenttypes.fields import GenericForeignKey
10 from django.contrib.contenttypes.models import ContentType
11 from django.db import models
12 from django.db.models.signals import m2m_changed
13 from django.dispatch import receiver
14 from django.forms.models import model_to_dict
15 from django.template.defaultfilters import yesno
16 from django.utils.formats import localize
17 from django.utils.translation import gettext_lazy as _
18 
19 from evap.evaluation.tools import capitalize_first
20 
21 
22 class FieldActionType(str, Enum):
23     M2M_ADD = "add"
24     M2M_REMOVE = "remove"
25     M2M_CLEAR = "clear"
26     INSTANCE_CREATE = "create"
27     VALUE_CHANGE = "change"
28     INSTANCE_DELETE = "delete"
29 
30 
31 FieldAction = namedtuple("FieldAction", "label type items")
32 
33 
34 class InstanceActionType(str, Enum):
35     CREATE = "create"
36     CHANGE = "change"
37     DELETE = "delete"
38 
39 
40 class LogJSONEncoder(JSONEncoder):
41     """
42     As JSON can't store datetime objects, we localize them to strings.
43     """
44 
45     def default(self, o):
46         # o is the object to serialize -- we can't rename the argument in JSONEncoder
47         if isinstance(o, (date, time, datetime)):
48             return localize(o)
49         return super().default(o)
50 
51 
52 def _choice_to_display(field, choice):  # does not support nested choices
53     for key, label in field.choices:
54         if key == choice:
55             return label
56     return choice
57 
58 
59 def _field_actions_for_field(field, actions):
60     label = capitalize_first(getattr(field, "verbose_name", field.name))
61 
62     for field_action_type, items in actions.items():
63         if field.many_to_many or field.many_to_one or field.one_to_one:
64             # convert item values from primary keys to string-representation for relation-based fields
65             related_objects = field.related_model.objects.filter(pk__in=items)
66             missing = len(items) - related_objects.count()
67             items = [str(obj) for obj in related_objects] + [_("<deleted object>")] * missing
68         elif hasattr(field, "choices") and field.choices:
69             # convert values from choice-based fields to their display equivalent
70             items = [_choice_to_display(field, item) for item in items]
71         elif isinstance(field, models.BooleanField):
72             # convert boolean to yes/no
73             items = list(map(yesno, items))
74         yield FieldAction(label, field_action_type, items)
75 
76 
77 class LogEntry(models.Model):
78     content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, related_name="logs_about_me")
79     content_object_id = models.PositiveIntegerField(db_index=True)
80     content_object = GenericForeignKey("content_type", "content_object_id")
81     attached_to_object_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, related_name="logs_for_me")
82     attached_to_object_id = models.PositiveIntegerField(db_index=True)
83     attached_to_object = GenericForeignKey("attached_to_object_type", "attached_to_object_id")
84     datetime = models.DateTimeField(auto_now_add=True)
85     user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.PROTECT)
86     action_type = models.CharField(max_length=255, choices=[(value, value) for value in InstanceActionType])
87     request_id = models.CharField(max_length=36, null=True, blank=True)
88     data = models.JSONField(default=dict, encoder=LogJSONEncoder)
89 
90     class Meta:
91         ordering = ["-datetime", "-id"]
92 
93     @property
94     def field_context_data(self):
95         model = self.content_type.model_class()
96         return {
97             field_name: [
98                 getattr(model, "transform_log_action", LoggedModel.transform_log_action)(field_action)
99                 for field_action in _field_actions_for_field(model._meta.get_field(field_name), actions)
100             ]
101             for field_name, actions in self.data.items()
102         }
103 
104     @property
105     def message(self):
106         if self.action_type == InstanceActionType.CHANGE:
107             if self.content_object:
108                 message = _("The {cls} {obj} was changed.")
109             else:  # content_object might be deleted
110                 message = _("A {cls} was changed.")
111         elif self.action_type == InstanceActionType.CREATE:
112             if self.content_object:
113                 message = _("The {cls} {obj} was created.")
114             else:
115                 message = _("A {cls} was created.")
116         elif self.action_type == InstanceActionType.DELETE:
117             message = _("A {cls} was deleted.")
118 
119         return message.format(
120             cls=capitalize_first(self.content_type.model_class()._meta.verbose_name),
121             obj=f'"{str(self.content_object)}"' if self.content_object else "",
122         )
123 
124 
125 class LoggedModel(models.Model):
126     thread = threading.local()
127 
128     class Meta:
129         abstract = True
130 
131     def __init__(self, *args, **kwargs):
132         super().__init__(*args, **kwargs)
133         self._logentry = None
134 
135     def _as_dict(self):
136         """
137         Return a dict mapping field names to values saved in this instance.
138         Only include field names that are not to be ignored for logging and
139         that don't name m2m fields.
140         """
141         fields = [
142             field.name
143             for field in type(self)._meta.get_fields()
144             if field.name not in self.unlogged_fields and not field.many_to_many
145         ]
146         return model_to_dict(self, fields)
147 
148     def _get_change_data(self, action_type: InstanceActionType):
149         """
150         Return a dict mapping field names to changes that happened in this model instance,
151         depending on the action that is being done to the instance.
152         """
153         self_dict = self._as_dict()
154         if action_type == InstanceActionType.CREATE:
155             changes = {
156                 field_name: {FieldActionType.INSTANCE_CREATE: [created_value]}
157                 for field_name, created_value in self_dict.items()
158                 if created_value is not None
159             }
160         elif action_type == InstanceActionType.CHANGE:
161             old_dict = type(self).objects.get(pk=self.pk)._as_dict()
162             changes = {
163                 field_name: {FieldActionType.VALUE_CHANGE: [old_value, self_dict[field_name]]}
164                 for field_name, old_value in old_dict.items()
165                 if old_value != self_dict[field_name]
166             }
167         elif action_type == InstanceActionType.DELETE:
168             old_dict = type(self).objects.get(pk=self.pk)._as_dict()
169             changes = {
170                 field_name: {FieldActionType.INSTANCE_DELETE: [deleted_value]}
171                 for field_name, deleted_value in old_dict.items()
172                 if deleted_value is not None
173             }
174             # as the instance is being deleted, we also need to pull out all m2m values
175             m2m_field_names = [
176                 field.name for field in type(self)._meta.many_to_many if field.name not in self.unlogged_fields
177             ]
178             for field_name, related_objects in model_to_dict(self, m2m_field_names).items():
179                 changes[field_name] = {FieldActionType.INSTANCE_DELETE: [obj.pk for obj in related_objects]}
180         else:
181             raise ValueError("Unknown action type: '{}'".format(action_type))
182 
183         return changes
184 
185     def log_m2m_change(self, changes):
186         self._update_log(changes, InstanceActionType.CHANGE)
187 
188     def log_instance_create(self):
189         changes = self._get_change_data(InstanceActionType.CREATE)
190         self._update_log(changes, InstanceActionType.CREATE)
191 
192     def log_instance_change(self):
193         changes = self._get_change_data(InstanceActionType.CHANGE)
194         self._update_log(changes, InstanceActionType.CHANGE)
195 
196     def log_instance_delete(self):
197         changes = self._get_change_data(InstanceActionType.DELETE)
198         self._update_log(changes, InstanceActionType.DELETE)
199 
200     def _update_log(self, changes, action_type: InstanceActionType):
201         if not changes:
202             return
203 
204         if not self._logentry:
205             try:
206                 user = self.thread.request.user
207                 request_id = self.thread.request_id
208             except AttributeError:
209                 user = None
210                 request_id = None
211             attach_to_model, attached_to_object_id = self.object_to_attach_logentries_to
212             attached_to_object_type = ContentType.objects.get_for_model(attach_to_model)
213             self._logentry = LogEntry(
214                 content_object=self,
215                 attached_to_object_type=attached_to_object_type,
216                 attached_to_object_id=attached_to_object_id,
217                 user=user,
218                 request_id=request_id,
219                 action_type=action_type,
220                 data=changes,
221             )
222         else:
223             self._logentry.data.update(changes)
224 
225         self._logentry.save()
226 
227     def save(self, *args, **kw):
228         # Are we creating a new instance?
229         # https://docs.djangoproject.com/en/3.0/ref/models/instances/#customizing-model-loading
230         if self._state.adding:
231             # we need to attach a logentry to an existing object, so we save this newly created instance first
232             super().save(*args, **kw)
233             self.log_instance_create()
234         else:
235             # when saving an existing instance, we get changes by comparing to the version from the database
236             # therefore we save the instance after building the logentry
237             self.log_instance_change()
238             super().save(*args, **kw)
239 
240     def delete(self, *args, **kw):
241         self.log_instance_delete()
242         self.related_logentries().delete()
243         super().delete(*args, **kw)
244 
245     def related_logentries(self):
246         """
247         Return a queryset with all logentries that should be shown with this model.
248         """
249         return LogEntry.objects.filter(
250             attached_to_object_type=ContentType.objects.get_for_model(type(self)),
251             attached_to_object_id=self.pk,
252         )
253 
254     def grouped_logentries(self):
255         """
256         Returns a list of lists of logentries for display. The order is not changed.
257         Logentries are grouped if they have a matching request_id.
258         """
259         yield from (
260             list(group)
261             for key, group in itertools.groupby(
262                 self.related_logentries().select_related("user"),
263                 lambda entry: entry.request_id or entry.pk,
264             )
265         )
266 
267     @property
268     def object_to_attach_logentries_to(self):
269         """
270         Return a model class and primary key for the object for which this logentry should be shown.
271         By default, show it to the object described by the logentry itself.
272 
273         Returning the model instance directly might rely on fetching that object from the database,
274         which can break bulk loading in some cases, so we don't do that.
275         """
276         return type(self), self.pk
277 
278     @property
279     def unlogged_fields(self):
280         """Specify a list of field names so that these fields don't get logged."""
281         return ["id", "order"]
282 
283     @staticmethod
284     def transform_log_action(field_action):
285         return field_action
286 
287 
288 @receiver(m2m_changed)
289 def _m2m_changed(sender, instance, action, reverse, model, pk_set, **kwargs):  # pylint: disable=unused-argument
290     if reverse:
291         return
292     if not isinstance(instance, LoggedModel):
293         return
294 
295     field_name = next(
296         (
297             field.name
298             for field in type(instance)._meta.many_to_many
299             if getattr(type(instance), field.name).through == sender
300         ),
301         None,
302     )
303 
304     if field_name in instance.unlogged_fields:
305         return
306 
307     m2m_changes = defaultdict(lambda: defaultdict(list))
308     if action == "pre_remove":
309         m2m_changes[field_name][FieldActionType.M2M_REMOVE] += list(pk_set)
310     elif action == "pre_add":
311         m2m_changes[field_name][FieldActionType.M2M_ADD] += list(pk_set)
312     elif action == "pre_clear":
313         m2m_changes[field_name][FieldActionType.M2M_CLEAR] = []
314 
315     if m2m_changes:
316         instance.log_m2m_change(m2m_changes)
317
```
--- END FILES ---

Please first localize the bug based on the issue statement, and then generate a patch according to the `git diff` format fenced by three backticks.

Here is an example:

```diff
diff --git a/examples/server_async.py b/examples/server_async.py
--- a/examples/server_async.py
+++ b/examples/server_async.py
@@ -313,4 +313,4 @@
 
 
 if __name__ == "__main__":
-    asyncio.run(run_async_server("."), debug=True)
+    asyncio.run(run_async_server(), debug=True)
diff --git a/examples/server_sync.py b/examples/server_sync.py
--- a/examples/server_sync.py
+++ b/examples/server_sync.py
@@ -313,5 +313,5 @@
 
 
 if __name__ == "__main__":
-    server = run_sync_server(".")
+    server = run_sync_server()
     server.shutdown()

```

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 @@
     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 @@
 
         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 @@
     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, [])

{"golden_diff": "diff --git a/evap/evaluation/models_logging.py b/evap/evaluation/models_logging.py\n--- a/evap/evaluation/models_logging.py\n+++ b/evap/evaluation/models_logging.py\n@@ -131,6 +131,7 @@\n     def __init__(self, *args, **kwargs):\n         super().__init__(*args, **kwargs)\n         self._logentry = None\n+        self._m2m_changes = defaultdict(lambda: defaultdict(list))\n \n     def _as_dict(self):\n         \"\"\"\n@@ -182,8 +183,12 @@\n \n         return changes\n \n-    def log_m2m_change(self, changes):\n-        self._update_log(changes, InstanceActionType.CHANGE)\n+    def log_m2m_change(self, field_name, action_type: FieldActionType, change_list):\n+        # This might be called multiple times with cumulating changes\n+        # But this is fine, since the old changes will be included in the latest log update\n+        # See https://github.com/e-valuation/EvaP/issues/1594\n+        self._m2m_changes[field_name][action_type] += change_list\n+        self._update_log(self._m2m_changes, InstanceActionType.CHANGE)\n \n     def log_instance_create(self):\n         changes = self._get_change_data(InstanceActionType.CREATE)\n@@ -304,13 +309,9 @@\n     if field_name in instance.unlogged_fields:\n         return\n \n-    m2m_changes = defaultdict(lambda: defaultdict(list))\n     if action == \"pre_remove\":\n-        m2m_changes[field_name][FieldActionType.M2M_REMOVE] += list(pk_set)\n+        instance.log_m2m_change(field_name, FieldActionType.M2M_REMOVE, list(pk_set))\n     elif action == \"pre_add\":\n-        m2m_changes[field_name][FieldActionType.M2M_ADD] += list(pk_set)\n+        instance.log_m2m_change(field_name, FieldActionType.M2M_ADD, list(pk_set))\n     elif action == \"pre_clear\":\n-        m2m_changes[field_name][FieldActionType.M2M_CLEAR] = []\n-\n-    if m2m_changes:\n-        instance.log_m2m_change(m2m_changes)\n+        instance.log_m2m_change(field_name, FieldActionType.M2M_CLEAR, [])\n", "issue": "Log M2M Clear\nWith #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.\r\nThe log only lists the newly added participants/contributors and does not show an entry for the deleted ones.\r\nThe deleted participants/contributors should also be logged.\n", "before_files": [{"content": "from collections import defaultdict, namedtuple\nfrom datetime import date, datetime, time\nfrom enum import Enum\nimport itertools\nimport threading\nfrom json import JSONEncoder\n\nfrom django.conf import settings\nfrom django.contrib.contenttypes.fields import GenericForeignKey\nfrom django.contrib.contenttypes.models import ContentType\nfrom django.db import models\nfrom django.db.models.signals import m2m_changed\nfrom django.dispatch import receiver\nfrom django.forms.models import model_to_dict\nfrom django.template.defaultfilters import yesno\nfrom django.utils.formats import localize\nfrom django.utils.translation import gettext_lazy as _\n\nfrom evap.evaluation.tools import capitalize_first\n\n\nclass FieldActionType(str, Enum):\n    M2M_ADD = \"add\"\n    M2M_REMOVE = \"remove\"\n    M2M_CLEAR = \"clear\"\n    INSTANCE_CREATE = \"create\"\n    VALUE_CHANGE = \"change\"\n    INSTANCE_DELETE = \"delete\"\n\n\nFieldAction = namedtuple(\"FieldAction\", \"label type items\")\n\n\nclass InstanceActionType(str, Enum):\n    CREATE = \"create\"\n    CHANGE = \"change\"\n    DELETE = \"delete\"\n\n\nclass LogJSONEncoder(JSONEncoder):\n    \"\"\"\n    As JSON can't store datetime objects, we localize them to strings.\n    \"\"\"\n\n    def default(self, o):\n        # o is the object to serialize -- we can't rename the argument in JSONEncoder\n        if isinstance(o, (date, time, datetime)):\n            return localize(o)\n        return super().default(o)\n\n\ndef _choice_to_display(field, choice):  # does not support nested choices\n    for key, label in field.choices:\n        if key == choice:\n            return label\n    return choice\n\n\ndef _field_actions_for_field(field, actions):\n    label = capitalize_first(getattr(field, \"verbose_name\", field.name))\n\n    for field_action_type, items in actions.items():\n        if field.many_to_many or field.many_to_one or field.one_to_one:\n            # convert item values from primary keys to string-representation for relation-based fields\n            related_objects = field.related_model.objects.filter(pk__in=items)\n            missing = len(items) - related_objects.count()\n            items = [str(obj) for obj in related_objects] + [_(\"<deleted object>\")] * missing\n        elif hasattr(field, \"choices\") and field.choices:\n            # convert values from choice-based fields to their display equivalent\n            items = [_choice_to_display(field, item) for item in items]\n        elif isinstance(field, models.BooleanField):\n            # convert boolean to yes/no\n            items = list(map(yesno, items))\n        yield FieldAction(label, field_action_type, items)\n\n\nclass LogEntry(models.Model):\n    content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, related_name=\"logs_about_me\")\n    content_object_id = models.PositiveIntegerField(db_index=True)\n    content_object = GenericForeignKey(\"content_type\", \"content_object_id\")\n    attached_to_object_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, related_name=\"logs_for_me\")\n    attached_to_object_id = models.PositiveIntegerField(db_index=True)\n    attached_to_object = GenericForeignKey(\"attached_to_object_type\", \"attached_to_object_id\")\n    datetime = models.DateTimeField(auto_now_add=True)\n    user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.PROTECT)\n    action_type = models.CharField(max_length=255, choices=[(value, value) for value in InstanceActionType])\n    request_id = models.CharField(max_length=36, null=True, blank=True)\n    data = models.JSONField(default=dict, encoder=LogJSONEncoder)\n\n    class Meta:\n        ordering = [\"-datetime\", \"-id\"]\n\n    @property\n    def field_context_data(self):\n        model = self.content_type.model_class()\n        return {\n            field_name: [\n                getattr(model, \"transform_log_action\", LoggedModel.transform_log_action)(field_action)\n                for field_action in _field_actions_for_field(model._meta.get_field(field_name), actions)\n            ]\n            for field_name, actions in self.data.items()\n        }\n\n    @property\n    def message(self):\n        if self.action_type == InstanceActionType.CHANGE:\n            if self.content_object:\n                message = _(\"The {cls} {obj} was changed.\")\n            else:  # content_object might be deleted\n                message = _(\"A {cls} was changed.\")\n        elif self.action_type == InstanceActionType.CREATE:\n            if self.content_object:\n                message = _(\"The {cls} {obj} was created.\")\n            else:\n                message = _(\"A {cls} was created.\")\n        elif self.action_type == InstanceActionType.DELETE:\n            message = _(\"A {cls} was deleted.\")\n\n        return message.format(\n            cls=capitalize_first(self.content_type.model_class()._meta.verbose_name),\n            obj=f'\"{str(self.content_object)}\"' if self.content_object else \"\",\n        )\n\n\nclass LoggedModel(models.Model):\n    thread = threading.local()\n\n    class Meta:\n        abstract = True\n\n    def __init__(self, *args, **kwargs):\n        super().__init__(*args, **kwargs)\n        self._logentry = None\n\n    def _as_dict(self):\n        \"\"\"\n        Return a dict mapping field names to values saved in this instance.\n        Only include field names that are not to be ignored for logging and\n        that don't name m2m fields.\n        \"\"\"\n        fields = [\n            field.name\n            for field in type(self)._meta.get_fields()\n            if field.name not in self.unlogged_fields and not field.many_to_many\n        ]\n        return model_to_dict(self, fields)\n\n    def _get_change_data(self, action_type: InstanceActionType):\n        \"\"\"\n        Return a dict mapping field names to changes that happened in this model instance,\n        depending on the action that is being done to the instance.\n        \"\"\"\n        self_dict = self._as_dict()\n        if action_type == InstanceActionType.CREATE:\n            changes = {\n                field_name: {FieldActionType.INSTANCE_CREATE: [created_value]}\n                for field_name, created_value in self_dict.items()\n                if created_value is not None\n            }\n        elif action_type == InstanceActionType.CHANGE:\n            old_dict = type(self).objects.get(pk=self.pk)._as_dict()\n            changes = {\n                field_name: {FieldActionType.VALUE_CHANGE: [old_value, self_dict[field_name]]}\n                for field_name, old_value in old_dict.items()\n                if old_value != self_dict[field_name]\n            }\n        elif action_type == InstanceActionType.DELETE:\n            old_dict = type(self).objects.get(pk=self.pk)._as_dict()\n            changes = {\n                field_name: {FieldActionType.INSTANCE_DELETE: [deleted_value]}\n                for field_name, deleted_value in old_dict.items()\n                if deleted_value is not None\n            }\n            # as the instance is being deleted, we also need to pull out all m2m values\n            m2m_field_names = [\n                field.name for field in type(self)._meta.many_to_many if field.name not in self.unlogged_fields\n            ]\n            for field_name, related_objects in model_to_dict(self, m2m_field_names).items():\n                changes[field_name] = {FieldActionType.INSTANCE_DELETE: [obj.pk for obj in related_objects]}\n        else:\n            raise ValueError(\"Unknown action type: '{}'\".format(action_type))\n\n        return changes\n\n    def log_m2m_change(self, changes):\n        self._update_log(changes, InstanceActionType.CHANGE)\n\n    def log_instance_create(self):\n        changes = self._get_change_data(InstanceActionType.CREATE)\n        self._update_log(changes, InstanceActionType.CREATE)\n\n    def log_instance_change(self):\n        changes = self._get_change_data(InstanceActionType.CHANGE)\n        self._update_log(changes, InstanceActionType.CHANGE)\n\n    def log_instance_delete(self):\n        changes = self._get_change_data(InstanceActionType.DELETE)\n        self._update_log(changes, InstanceActionType.DELETE)\n\n    def _update_log(self, changes, action_type: InstanceActionType):\n        if not changes:\n            return\n\n        if not self._logentry:\n            try:\n                user = self.thread.request.user\n                request_id = self.thread.request_id\n            except AttributeError:\n                user = None\n                request_id = None\n            attach_to_model, attached_to_object_id = self.object_to_attach_logentries_to\n            attached_to_object_type = ContentType.objects.get_for_model(attach_to_model)\n            self._logentry = LogEntry(\n                content_object=self,\n                attached_to_object_type=attached_to_object_type,\n                attached_to_object_id=attached_to_object_id,\n                user=user,\n                request_id=request_id,\n                action_type=action_type,\n                data=changes,\n            )\n        else:\n            self._logentry.data.update(changes)\n\n        self._logentry.save()\n\n    def save(self, *args, **kw):\n        # Are we creating a new instance?\n        # https://docs.djangoproject.com/en/3.0/ref/models/instances/#customizing-model-loading\n        if self._state.adding:\n            # we need to attach a logentry to an existing object, so we save this newly created instance first\n            super().save(*args, **kw)\n            self.log_instance_create()\n        else:\n            # when saving an existing instance, we get changes by comparing to the version from the database\n            # therefore we save the instance after building the logentry\n            self.log_instance_change()\n            super().save(*args, **kw)\n\n    def delete(self, *args, **kw):\n        self.log_instance_delete()\n        self.related_logentries().delete()\n        super().delete(*args, **kw)\n\n    def related_logentries(self):\n        \"\"\"\n        Return a queryset with all logentries that should be shown with this model.\n        \"\"\"\n        return LogEntry.objects.filter(\n            attached_to_object_type=ContentType.objects.get_for_model(type(self)),\n            attached_to_object_id=self.pk,\n        )\n\n    def grouped_logentries(self):\n        \"\"\"\n        Returns a list of lists of logentries for display. The order is not changed.\n        Logentries are grouped if they have a matching request_id.\n        \"\"\"\n        yield from (\n            list(group)\n            for key, group in itertools.groupby(\n                self.related_logentries().select_related(\"user\"),\n                lambda entry: entry.request_id or entry.pk,\n            )\n        )\n\n    @property\n    def object_to_attach_logentries_to(self):\n        \"\"\"\n        Return a model class and primary key for the object for which this logentry should be shown.\n        By default, show it to the object described by the logentry itself.\n\n        Returning the model instance directly might rely on fetching that object from the database,\n        which can break bulk loading in some cases, so we don't do that.\n        \"\"\"\n        return type(self), self.pk\n\n    @property\n    def unlogged_fields(self):\n        \"\"\"Specify a list of field names so that these fields don't get logged.\"\"\"\n        return [\"id\", \"order\"]\n\n    @staticmethod\n    def transform_log_action(field_action):\n        return field_action\n\n\n@receiver(m2m_changed)\ndef _m2m_changed(sender, instance, action, reverse, model, pk_set, **kwargs):  # pylint: disable=unused-argument\n    if reverse:\n        return\n    if not isinstance(instance, LoggedModel):\n        return\n\n    field_name = next(\n        (\n            field.name\n            for field in type(instance)._meta.many_to_many\n            if getattr(type(instance), field.name).through == sender\n        ),\n        None,\n    )\n\n    if field_name in instance.unlogged_fields:\n        return\n\n    m2m_changes = defaultdict(lambda: defaultdict(list))\n    if action == \"pre_remove\":\n        m2m_changes[field_name][FieldActionType.M2M_REMOVE] += list(pk_set)\n    elif action == \"pre_add\":\n        m2m_changes[field_name][FieldActionType.M2M_ADD] += list(pk_set)\n    elif action == \"pre_clear\":\n        m2m_changes[field_name][FieldActionType.M2M_CLEAR] = []\n\n    if m2m_changes:\n        instance.log_m2m_change(m2m_changes)\n", "path": "evap/evaluation/models_logging.py"}], "after_files": [{"content": "from collections import defaultdict, namedtuple\nfrom datetime import date, datetime, time\nfrom enum import Enum\nimport itertools\nimport threading\nfrom json import JSONEncoder\n\nfrom django.conf import settings\nfrom django.contrib.contenttypes.fields import GenericForeignKey\nfrom django.contrib.contenttypes.models import ContentType\nfrom django.db import models\nfrom django.db.models.signals import m2m_changed\nfrom django.dispatch import receiver\nfrom django.forms.models import model_to_dict\nfrom django.template.defaultfilters import yesno\nfrom django.utils.formats import localize\nfrom django.utils.translation import gettext_lazy as _\n\nfrom evap.evaluation.tools import capitalize_first\n\n\nclass FieldActionType(str, Enum):\n    M2M_ADD = \"add\"\n    M2M_REMOVE = \"remove\"\n    M2M_CLEAR = \"clear\"\n    INSTANCE_CREATE = \"create\"\n    VALUE_CHANGE = \"change\"\n    INSTANCE_DELETE = \"delete\"\n\n\nFieldAction = namedtuple(\"FieldAction\", \"label type items\")\n\n\nclass InstanceActionType(str, Enum):\n    CREATE = \"create\"\n    CHANGE = \"change\"\n    DELETE = \"delete\"\n\n\nclass LogJSONEncoder(JSONEncoder):\n    \"\"\"\n    As JSON can't store datetime objects, we localize them to strings.\n    \"\"\"\n\n    def default(self, o):\n        # o is the object to serialize -- we can't rename the argument in JSONEncoder\n        if isinstance(o, (date, time, datetime)):\n            return localize(o)\n        return super().default(o)\n\n\ndef _choice_to_display(field, choice):  # does not support nested choices\n    for key, label in field.choices:\n        if key == choice:\n            return label\n    return choice\n\n\ndef _field_actions_for_field(field, actions):\n    label = capitalize_first(getattr(field, \"verbose_name\", field.name))\n\n    for field_action_type, items in actions.items():\n        if field.many_to_many or field.many_to_one or field.one_to_one:\n            # convert item values from primary keys to string-representation for relation-based fields\n            related_objects = field.related_model.objects.filter(pk__in=items)\n            missing = len(items) - related_objects.count()\n            items = [str(obj) for obj in related_objects] + [_(\"<deleted object>\")] * missing\n        elif hasattr(field, \"choices\") and field.choices:\n            # convert values from choice-based fields to their display equivalent\n            items = [_choice_to_display(field, item) for item in items]\n        elif isinstance(field, models.BooleanField):\n            # convert boolean to yes/no\n            items = list(map(yesno, items))\n        yield FieldAction(label, field_action_type, items)\n\n\nclass LogEntry(models.Model):\n    content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, related_name=\"logs_about_me\")\n    content_object_id = models.PositiveIntegerField(db_index=True)\n    content_object = GenericForeignKey(\"content_type\", \"content_object_id\")\n    attached_to_object_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, related_name=\"logs_for_me\")\n    attached_to_object_id = models.PositiveIntegerField(db_index=True)\n    attached_to_object = GenericForeignKey(\"attached_to_object_type\", \"attached_to_object_id\")\n    datetime = models.DateTimeField(auto_now_add=True)\n    user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.PROTECT)\n    action_type = models.CharField(max_length=255, choices=[(value, value) for value in InstanceActionType])\n    request_id = models.CharField(max_length=36, null=True, blank=True)\n    data = models.JSONField(default=dict, encoder=LogJSONEncoder)\n\n    class Meta:\n        ordering = [\"-datetime\", \"-id\"]\n\n    @property\n    def field_context_data(self):\n        model = self.content_type.model_class()\n        return {\n            field_name: [\n                getattr(model, \"transform_log_action\", LoggedModel.transform_log_action)(field_action)\n                for field_action in _field_actions_for_field(model._meta.get_field(field_name), actions)\n            ]\n            for field_name, actions in self.data.items()\n        }\n\n    @property\n    def message(self):\n        if self.action_type == InstanceActionType.CHANGE:\n            if self.content_object:\n                message = _(\"The {cls} {obj} was changed.\")\n            else:  # content_object might be deleted\n                message = _(\"A {cls} was changed.\")\n        elif self.action_type == InstanceActionType.CREATE:\n            if self.content_object:\n                message = _(\"The {cls} {obj} was created.\")\n            else:\n                message = _(\"A {cls} was created.\")\n        elif self.action_type == InstanceActionType.DELETE:\n            message = _(\"A {cls} was deleted.\")\n\n        return message.format(\n            cls=capitalize_first(self.content_type.model_class()._meta.verbose_name),\n            obj=f'\"{str(self.content_object)}\"' if self.content_object else \"\",\n        )\n\n\nclass LoggedModel(models.Model):\n    thread = threading.local()\n\n    class Meta:\n        abstract = True\n\n    def __init__(self, *args, **kwargs):\n        super().__init__(*args, **kwargs)\n        self._logentry = None\n        self._m2m_changes = defaultdict(lambda: defaultdict(list))\n\n    def _as_dict(self):\n        \"\"\"\n        Return a dict mapping field names to values saved in this instance.\n        Only include field names that are not to be ignored for logging and\n        that don't name m2m fields.\n        \"\"\"\n        fields = [\n            field.name\n            for field in type(self)._meta.get_fields()\n            if field.name not in self.unlogged_fields and not field.many_to_many\n        ]\n        return model_to_dict(self, fields)\n\n    def _get_change_data(self, action_type: InstanceActionType):\n        \"\"\"\n        Return a dict mapping field names to changes that happened in this model instance,\n        depending on the action that is being done to the instance.\n        \"\"\"\n        self_dict = self._as_dict()\n        if action_type == InstanceActionType.CREATE:\n            changes = {\n                field_name: {FieldActionType.INSTANCE_CREATE: [created_value]}\n                for field_name, created_value in self_dict.items()\n                if created_value is not None\n            }\n        elif action_type == InstanceActionType.CHANGE:\n            old_dict = type(self).objects.get(pk=self.pk)._as_dict()\n            changes = {\n                field_name: {FieldActionType.VALUE_CHANGE: [old_value, self_dict[field_name]]}\n                for field_name, old_value in old_dict.items()\n                if old_value != self_dict[field_name]\n            }\n        elif action_type == InstanceActionType.DELETE:\n            old_dict = type(self).objects.get(pk=self.pk)._as_dict()\n            changes = {\n                field_name: {FieldActionType.INSTANCE_DELETE: [deleted_value]}\n                for field_name, deleted_value in old_dict.items()\n                if deleted_value is not None\n            }\n            # as the instance is being deleted, we also need to pull out all m2m values\n            m2m_field_names = [\n                field.name for field in type(self)._meta.many_to_many if field.name not in self.unlogged_fields\n            ]\n            for field_name, related_objects in model_to_dict(self, m2m_field_names).items():\n                changes[field_name] = {FieldActionType.INSTANCE_DELETE: [obj.pk for obj in related_objects]}\n        else:\n            raise ValueError(\"Unknown action type: '{}'\".format(action_type))\n\n        return changes\n\n    def log_m2m_change(self, field_name, action_type: FieldActionType, change_list):\n        # This might be called multiple times with cumulating changes\n        # But this is fine, since the old changes will be included in the latest log update\n        # See https://github.com/e-valuation/EvaP/issues/1594\n        self._m2m_changes[field_name][action_type] += change_list\n        self._update_log(self._m2m_changes, InstanceActionType.CHANGE)\n\n    def log_instance_create(self):\n        changes = self._get_change_data(InstanceActionType.CREATE)\n        self._update_log(changes, InstanceActionType.CREATE)\n\n    def log_instance_change(self):\n        changes = self._get_change_data(InstanceActionType.CHANGE)\n        self._update_log(changes, InstanceActionType.CHANGE)\n\n    def log_instance_delete(self):\n        changes = self._get_change_data(InstanceActionType.DELETE)\n        self._update_log(changes, InstanceActionType.DELETE)\n\n    def _update_log(self, changes, action_type: InstanceActionType):\n        if not changes:\n            return\n\n        if not self._logentry:\n            try:\n                user = self.thread.request.user\n                request_id = self.thread.request_id\n            except AttributeError:\n                user = None\n                request_id = None\n            attach_to_model, attached_to_object_id = self.object_to_attach_logentries_to\n            attached_to_object_type = ContentType.objects.get_for_model(attach_to_model)\n            self._logentry = LogEntry(\n                content_object=self,\n                attached_to_object_type=attached_to_object_type,\n                attached_to_object_id=attached_to_object_id,\n                user=user,\n                request_id=request_id,\n                action_type=action_type,\n                data=changes,\n            )\n        else:\n            self._logentry.data.update(changes)\n\n        self._logentry.save()\n\n    def save(self, *args, **kw):\n        # Are we creating a new instance?\n        # https://docs.djangoproject.com/en/3.0/ref/models/instances/#customizing-model-loading\n        if self._state.adding:\n            # we need to attach a logentry to an existing object, so we save this newly created instance first\n            super().save(*args, **kw)\n            self.log_instance_create()\n        else:\n            # when saving an existing instance, we get changes by comparing to the version from the database\n            # therefore we save the instance after building the logentry\n            self.log_instance_change()\n            super().save(*args, **kw)\n\n    def delete(self, *args, **kw):\n        self.log_instance_delete()\n        self.related_logentries().delete()\n        super().delete(*args, **kw)\n\n    def related_logentries(self):\n        \"\"\"\n        Return a queryset with all logentries that should be shown with this model.\n        \"\"\"\n        return LogEntry.objects.filter(\n            attached_to_object_type=ContentType.objects.get_for_model(type(self)),\n            attached_to_object_id=self.pk,\n        )\n\n    def grouped_logentries(self):\n        \"\"\"\n        Returns a list of lists of logentries for display. The order is not changed.\n        Logentries are grouped if they have a matching request_id.\n        \"\"\"\n        yield from (\n            list(group)\n            for key, group in itertools.groupby(\n                self.related_logentries().select_related(\"user\"),\n                lambda entry: entry.request_id or entry.pk,\n            )\n        )\n\n    @property\n    def object_to_attach_logentries_to(self):\n        \"\"\"\n        Return a model class and primary key for the object for which this logentry should be shown.\n        By default, show it to the object described by the logentry itself.\n\n        Returning the model instance directly might rely on fetching that object from the database,\n        which can break bulk loading in some cases, so we don't do that.\n        \"\"\"\n        return type(self), self.pk\n\n    @property\n    def unlogged_fields(self):\n        \"\"\"Specify a list of field names so that these fields don't get logged.\"\"\"\n        return [\"id\", \"order\"]\n\n    @staticmethod\n    def transform_log_action(field_action):\n        return field_action\n\n\n@receiver(m2m_changed)\ndef _m2m_changed(sender, instance, action, reverse, model, pk_set, **kwargs):  # pylint: disable=unused-argument\n    if reverse:\n        return\n    if not isinstance(instance, LoggedModel):\n        return\n\n    field_name = next(\n        (\n            field.name\n            for field in type(instance)._meta.many_to_many\n            if getattr(type(instance), field.name).through == sender\n        ),\n        None,\n    )\n\n    if field_name in instance.unlogged_fields:\n        return\n\n    if action == \"pre_remove\":\n        instance.log_m2m_change(field_name, FieldActionType.M2M_REMOVE, list(pk_set))\n    elif action == \"pre_add\":\n        instance.log_m2m_change(field_name, FieldActionType.M2M_ADD, list(pk_set))\n    elif action == \"pre_clear\":\n        instance.log_m2m_change(field_name, FieldActionType.M2M_CLEAR, [])\n", "path": "evap/evaluation/models_logging.py"}]}