We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
deviations: columns Granter and Grant time are missing info
Kun monta saman henkilön poikkeamaa on "taiteltu plussan taakse", ei Granter- ja Grant time -sarakkeissa lue mitään edes siinä tapauksessa, että aika ja myöntäjä olisivat kaikilla piilotetuilla riveillä samat. 
Jos tiedot ovat kaikille saman plussan takana olevilla identtiset, voisivat ne näkyä.
(e.g. https://plus.cs.aalto.fi/o1/2021/teachers/deadline-deviations/)

--------------------------------------

In English
> It is extremely common that a student gets a deadline extension for multiple exercises at once. In such cases, the DL extensions table folds the exercises into one entry so that the view doesn’t get cluttered. That’s great, but it would be nice if the metadata — who granted the extension? when? — was shown for the folded items as well (assuming it’s one person and a single time, which should almost always be the case). This does not currently happen. The columns on the right are blank here:

https://github.com/apluslms/a-plus/issues/1121#issue-1517628848

--- END ISSUE ---

Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
--- BEGIN FILES ---
Path: `deviations/viewbase.py`
Content:
```
1 from itertools import groupby
2 from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Type
3 
4 from django.db import models
5 from django.http import HttpRequest, HttpResponse
6 from django.contrib import messages
7 from django import forms
8 from django.utils.text import format_lazy
9 from django.utils.translation import gettext_lazy as _, ngettext
10 
11 from course.models import CourseModule, UserTag
12 from course.viewbase import CourseInstanceMixin, CourseInstanceBaseView
13 from deviations.models import SubmissionRuleDeviation
14 from lib.helpers import is_ajax
15 from lib.viewbase import BaseFormView, BaseRedirectView
16 from authorization.permissions import ACCESS
17 from exercise.models import BaseExercise
18 from userprofile.models import UserProfile
19 
20 
21 class ListDeviationsView(CourseInstanceBaseView):
22     access_mode = ACCESS.TEACHER
23     deviation_model: Type[SubmissionRuleDeviation]
24 
25     def get_common_objects(self) -> None:
26         super().get_common_objects()
27         all_deviations = self.deviation_model.objects.filter(
28             exercise__course_module__course_instance=self.instance
29         )
30         self.deviation_groups = get_deviation_groups(all_deviations)
31         self.note("deviation_groups")
32 
33 
34 class AddDeviationsView(CourseInstanceMixin, BaseFormView):
35     access_mode = ACCESS.TEACHER
36     deviation_model: Type[SubmissionRuleDeviation]
37     session_key: str
38 
39     def get_form_kwargs(self) -> Dict[str, Any]:
40         kwargs = super().get_form_kwargs()
41         kwargs["instance"] = self.instance
42         return kwargs
43 
44     def get_initial_get_param_spec(self) -> Dict[str, Optional[Callable[[str], Any]]]:
45         def list_arg(arg):
46             return arg.split(",")
47 
48         spec = super().get_initial_get_param_spec()
49         spec.update({
50             "module": list_arg,
51             "exercise": list_arg,
52             "submitter": list_arg,
53             "submitter_tag": list_arg,
54         })
55         return spec
56 
57     def form_valid(self, form: forms.BaseForm) -> HttpResponse:
58         exercises = get_exercises(form.cleaned_data)
59         submitters = get_submitters(form.cleaned_data)
60         existing_deviations = self.deviation_model.objects.filter(
61             exercise__in=exercises,
62             submitter__in=submitters,
63         )
64 
65         if existing_deviations:
66             # Some deviations already existed. Use OverrideDeviationsView to
67             # confirm which ones the user wants to override. Store the form
68             # values in the current session, so they can be used afterwards.
69             self.success_url = self.deviation_model.get_override_url(self.instance)
70             self.request.session[self.session_key] = self.serialize_session_data(form.cleaned_data)
71         else:
72             self.success_url = self.deviation_model.get_list_url(self.instance)
73             for exercise in exercises:
74                 for submitter in submitters:
75                     new_deviation = self.deviation_model(
76                         exercise=exercise,
77                         submitter=submitter,
78                         granter=self.request.user.userprofile,
79                     )
80                     new_deviation.update_by_form(form.cleaned_data)
81                     new_deviation.save()
82 
83         return super().form_valid(form)
84 
85     def serialize_session_data(self, form_data: Dict[str, Any]) -> Dict[str, Any]:
86         """
87         Convert input form data into serializable values that can be stored in
88         the session cache.
89         """
90         result = {}
91         for key in ('exercise', 'module', 'submitter', 'submitter_tag'):
92             result[key] = [i.id for i in form_data.get(key, [])]
93         return result
94 
95 
96 class OverrideDeviationsView(CourseInstanceMixin, BaseFormView):
97     access_mode = ACCESS.TEACHER
98     # form_class is not really used, but it is required by the FormView.
99     # The form contains only checkboxes and the user input is validated in
100     # the form_valid method. The form HTML is manually written in the template.
101     form_class = forms.Form
102     deviation_model: Type[SubmissionRuleDeviation]
103     session_key: str
104 
105     def get_success_url(self) -> str:
106         return self.deviation_model.get_list_url(self.instance)
107 
108     def get_common_objects(self) -> None:
109         super().get_common_objects()
110         self.session_data = self.deserialize_session_data(self.request.session[self.session_key])
111         self.exercises = get_exercises(self.session_data)
112         self.submitters = get_submitters(self.session_data)
113         self.existing_deviations = self.deviation_model.objects.filter(
114             exercise__in=self.exercises,
115             submitter__in=self.submitters,
116         )
117         self.deviation_groups = get_deviation_groups(self.existing_deviations)
118         self.note("session_data", "exercises", "submitters", "existing_deviations", "deviation_groups")
119 
120     def form_valid(self, form: forms.BaseForm) -> HttpResponse:
121         override_deviations = set()
122         deviation_list = self.request.POST.getlist('override')
123         for id_pair in deviation_list:
124             try:
125                 submitter_id, exercise_id = id_pair.split('.')
126                 submitter_id, exercise_id = int(submitter_id), int(exercise_id)
127                 override_deviations.add((submitter_id, exercise_id))
128             except ValueError:
129                 messages.error(self.request,
130                     format_lazy(
131                         _("INVALID_EXERCISE_OR_SUBMITTER_ID -- {id}"),
132                         id=id_pair,
133                     )
134                 )
135                 continue
136 
137         existing_deviations = {(d.submitter_id, d.exercise_id): d for d in self.existing_deviations}
138 
139         for exercise in self.exercises:
140             for submitter in self.submitters:
141                 existing_deviation = existing_deviations.get((submitter.id, exercise.id))
142                 if existing_deviation is not None:
143                     if (submitter.id, exercise.id) in override_deviations:
144                         existing_deviation.granter = self.request.user.userprofile
145                         existing_deviation.update_by_form(self.session_data)
146                         existing_deviation.save()
147                 else:
148                     new_deviation = self.deviation_model(
149                         exercise=exercise,
150                         submitter=submitter,
151                         granter=self.request.user.userprofile,
152                     )
153                     new_deviation.update_by_form(self.session_data)
154                     new_deviation.save()
155 
156         del self.request.session[self.session_key]
157         return super().form_valid(form)
158 
159     def deserialize_session_data(self, session_data: Dict[str, Any]) -> Dict[str, Any]:
160         """
161         Convert serialized session data back into its original representation.
162         """
163         result = {
164             'exercise': BaseExercise.objects.filter(id__in=session_data.get('exercise', [])),
165             'module': CourseModule.objects.filter(id__in=session_data.get('module', [])),
166             'submitter': UserProfile.objects.filter(id__in=session_data.get('submitter', [])),
167             'submitter_tag': UserTag.objects.filter(id__in=session_data.get('submitter_tag', [])),
168         }
169         return result
170 
171 
172 class RemoveDeviationsByIDView(CourseInstanceMixin, BaseRedirectView):
173     access_mode = ACCESS.TEACHER
174     deviation_model: Type[SubmissionRuleDeviation]
175 
176     def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
177         deviations = self.deviation_model.objects.filter(
178             id__in=request.POST.getlist("id"),
179             exercise__course_module__course_instance=self.instance,
180         )
181         for deviation in deviations:
182             deviation.delete()
183         if is_ajax(request):
184             return HttpResponse(status=204)
185         return self.redirect(self.deviation_model.get_list_url(self.instance))
186 
187 
188 class RemoveDeviationsView(CourseInstanceMixin, BaseFormView):
189     access_mode = ACCESS.TEACHER
190     deviation_model: Type[SubmissionRuleDeviation]
191 
192     def get_form_kwargs(self) -> Dict[str, Any]:
193         kwargs = super().get_form_kwargs()
194         kwargs["instance"] = self.instance
195         return kwargs
196 
197     def get_success_url(self) -> str:
198         return self.deviation_model.get_list_url(self.instance)
199 
200     def form_valid(self, form: forms.BaseForm) -> HttpResponse:
201         number_of_removed = 0
202         deviations = self.deviation_model.objects.filter(
203             exercise__in=get_exercises(form.cleaned_data),
204             submitter__in=get_submitters(form.cleaned_data),
205         )
206         for deviation in deviations:
207             deviation.delete()
208             number_of_removed += 1
209         if number_of_removed == 0:
210             messages.warning(self.request, _("NOTHING_REMOVED"))
211         else:
212             message = ngettext(
213                 'REMOVED_DEVIATION -- {count}',
214                 'REMOVED_DEVIATIONS -- {count}',
215                 number_of_removed,
216             ).format(count=number_of_removed)
217             messages.info(self.request, message)
218         return super().form_valid(form)
219 
220 
221 def get_deviation_groups(
222         all_deviations: models.QuerySet[SubmissionRuleDeviation],
223         ) -> Iterable[Tuple[List[SubmissionRuleDeviation], bool, Optional[str]]]:
224     """
225     Group the deviations by user and module.
226 
227     Grouping condition: deviations can be grouped if the user has been
228     granted the same deviation (based on the `is_equal` method) for all
229     exercises in the module.
230 
231     The returned tuples contain the following values:
232     1. List of deviations with the same user and module.
233     2. Boolean representing whether the deviations in the list can be
234     displayed as a group (i.e. the grouping condition is satisfied).
235     3. An id that uniquely identifies the group of deviations.
236     """
237     # Find the number of exercises in each module.
238     course_instances = (
239         all_deviations
240         .values_list('exercise__course_module__course_instance', flat=True)
241         .distinct()
242     )
243     exercise_counts = (
244         BaseExercise.objects.filter(
245             course_module__course_instance__in=course_instances
246         )
247         .order_by()
248         .values('course_module_id')
249         .annotate(count=models.Count('*'))
250     )
251     exercise_count_by_module = {row['course_module_id']: row['count'] for row in exercise_counts}
252 
253     ordered_deviations = (
254         all_deviations
255         .select_related(
256             'submitter', 'submitter__user',
257             'granter', 'granter__user',
258             'exercise', 'exercise__course_module',
259         )
260         # parent is prefetched because there may be multiple ancestors, and
261         # they are needed for building the deviation's URL.
262         .prefetch_related('exercise__parent')
263         .order_by('submitter', 'exercise__course_module')
264     )
265 
266     deviation_groups = groupby(
267         ordered_deviations,
268         lambda obj: (obj.submitter, obj.exercise.course_module),
269     )
270     for (_submitter, module), deviations_iter in deviation_groups:
271         deviations = list(deviations_iter)
272         can_group = True
273         if len(deviations) < 2:
274             # Group must have at least 2 deviations.
275             can_group = False
276         else:
277             group_exercises = set()
278             # Check that the same deviation has been granted for all exercises.
279             for deviation in deviations:
280                 if not deviation.is_groupable(deviations[0]):
281                     can_group = False
282                     break
283                 group_exercises.add(deviation.exercise.id)
284             else:
285                 if len(group_exercises) != exercise_count_by_module[module.id]:
286                     # The number of exercises that have deviations doesn't
287                     # match the number of exercises in the module, so there
288                     # are some exercises that don't have a deviation.
289                     can_group = False
290         group_id = f"{deviations[0].submitter.id}.{module.id}" if can_group else None
291         yield (deviations, can_group, group_id)
292 
293 
294 def get_exercises(form_data: Dict[str, Any]) -> models.QuerySet[BaseExercise]:
295     """
296     Get the exercises that match the input form's `exercise` and `module`
297     fields.
298     """
299     return BaseExercise.objects.filter(
300         models.Q(id__in=form_data.get('exercise', []))
301         | models.Q(course_module__in=form_data.get('module', []))
302     )
303 
304 
305 def get_submitters(form_data: Dict[str, Any]) -> models.QuerySet[UserProfile]:
306     """
307     Get the submitters that match the input form's `submitter` and
308     `submitter_tag` fields.
309     """
310     return UserProfile.objects.filter(
311         models.Q(id__in=form_data.get('submitter', []))
312         | models.Q(taggings__tag__in=form_data.get('submitter_tag', []))
313     ).distinct()
314
```
--- 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/deviations/viewbase.py b/deviations/viewbase.py
--- a/deviations/viewbase.py
+++ b/deviations/viewbase.py
@@ -218,6 +218,7 @@
         return super().form_valid(form)
 
 
+# pylint: disable-next=too-many-locals
 def get_deviation_groups(
         all_deviations: models.QuerySet[SubmissionRuleDeviation],
         ) -> Iterable[Tuple[List[SubmissionRuleDeviation], bool, Optional[str]]]:
@@ -270,16 +271,23 @@
     for (_submitter, module), deviations_iter in deviation_groups:
         deviations = list(deviations_iter)
         can_group = True
+        show_granter = True
         if len(deviations) < 2:
             # Group must have at least 2 deviations.
             can_group = False
         else:
             group_exercises = set()
             # Check that the same deviation has been granted for all exercises.
+            first_granter = deviations[0].granter.id
             for deviation in deviations:
                 if not deviation.is_groupable(deviations[0]):
                     can_group = False
-                    break
+                    if not show_granter:
+                        break
+                if deviation.granter.id != first_granter:
+                    show_granter = False
+                    if not can_group:
+                        break
                 group_exercises.add(deviation.exercise.id)
             else:
                 if len(group_exercises) != exercise_count_by_module[module.id]:
@@ -288,7 +296,7 @@
                     # are some exercises that don't have a deviation.
                     can_group = False
         group_id = f"{deviations[0].submitter.id}.{module.id}" if can_group else None
-        yield (deviations, can_group, group_id)
+        yield (deviations, can_group, group_id, show_granter)
 
 
 def get_exercises(form_data: Dict[str, Any]) -> models.QuerySet[BaseExercise]:

{"golden_diff": "diff --git a/deviations/viewbase.py b/deviations/viewbase.py\n--- a/deviations/viewbase.py\n+++ b/deviations/viewbase.py\n@@ -218,6 +218,7 @@\n         return super().form_valid(form)\n \n \n+# pylint: disable-next=too-many-locals\n def get_deviation_groups(\n         all_deviations: models.QuerySet[SubmissionRuleDeviation],\n         ) -> Iterable[Tuple[List[SubmissionRuleDeviation], bool, Optional[str]]]:\n@@ -270,16 +271,23 @@\n     for (_submitter, module), deviations_iter in deviation_groups:\n         deviations = list(deviations_iter)\n         can_group = True\n+        show_granter = True\n         if len(deviations) < 2:\n             # Group must have at least 2 deviations.\n             can_group = False\n         else:\n             group_exercises = set()\n             # Check that the same deviation has been granted for all exercises.\n+            first_granter = deviations[0].granter.id\n             for deviation in deviations:\n                 if not deviation.is_groupable(deviations[0]):\n                     can_group = False\n-                    break\n+                    if not show_granter:\n+                        break\n+                if deviation.granter.id != first_granter:\n+                    show_granter = False\n+                    if not can_group:\n+                        break\n                 group_exercises.add(deviation.exercise.id)\n             else:\n                 if len(group_exercises) != exercise_count_by_module[module.id]:\n@@ -288,7 +296,7 @@\n                     # are some exercises that don't have a deviation.\n                     can_group = False\n         group_id = f\"{deviations[0].submitter.id}.{module.id}\" if can_group else None\n-        yield (deviations, can_group, group_id)\n+        yield (deviations, can_group, group_id, show_granter)\n \n \n def get_exercises(form_data: Dict[str, Any]) -> models.QuerySet[BaseExercise]:\n", "issue": "deviations: columns Granter and Grant time are missing info\nKun monta saman henkil\u00f6n poikkeamaa on \"taiteltu plussan taakse\", ei Granter- ja Grant time -sarakkeissa lue mit\u00e4\u00e4n edes siin\u00e4 tapauksessa, ett\u00e4 aika ja my\u00f6nt\u00e4j\u00e4 olisivat kaikilla piilotetuilla riveill\u00e4 samat. \r\nJos tiedot ovat kaikille saman plussan takana olevilla identtiset, voisivat ne n\u00e4ky\u00e4.\r\n(e.g. https://plus.cs.aalto.fi/o1/2021/teachers/deadline-deviations/)\r\n\r\n--------------------------------------\r\n\r\nIn English\r\n> It is extremely common that a student gets a deadline extension for multiple exercises at once. In such cases, the DL extensions table folds the exercises into one entry so that the view doesn\u2019t get cluttered. That\u2019s great, but it would be nice if the metadata \u2014 who granted the extension? when? \u2014 was shown for the folded items as well (assuming it\u2019s one person and a single time, which should almost always be the case). This does not currently happen. The columns on the right are blank here:\r\n\r\nhttps://github.com/apluslms/a-plus/issues/1121#issue-1517628848\n", "before_files": [{"content": "from itertools import groupby\nfrom typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Type\n\nfrom django.db import models\nfrom django.http import HttpRequest, HttpResponse\nfrom django.contrib import messages\nfrom django import forms\nfrom django.utils.text import format_lazy\nfrom django.utils.translation import gettext_lazy as _, ngettext\n\nfrom course.models import CourseModule, UserTag\nfrom course.viewbase import CourseInstanceMixin, CourseInstanceBaseView\nfrom deviations.models import SubmissionRuleDeviation\nfrom lib.helpers import is_ajax\nfrom lib.viewbase import BaseFormView, BaseRedirectView\nfrom authorization.permissions import ACCESS\nfrom exercise.models import BaseExercise\nfrom userprofile.models import UserProfile\n\n\nclass ListDeviationsView(CourseInstanceBaseView):\n    access_mode = ACCESS.TEACHER\n    deviation_model: Type[SubmissionRuleDeviation]\n\n    def get_common_objects(self) -> None:\n        super().get_common_objects()\n        all_deviations = self.deviation_model.objects.filter(\n            exercise__course_module__course_instance=self.instance\n        )\n        self.deviation_groups = get_deviation_groups(all_deviations)\n        self.note(\"deviation_groups\")\n\n\nclass AddDeviationsView(CourseInstanceMixin, BaseFormView):\n    access_mode = ACCESS.TEACHER\n    deviation_model: Type[SubmissionRuleDeviation]\n    session_key: str\n\n    def get_form_kwargs(self) -> Dict[str, Any]:\n        kwargs = super().get_form_kwargs()\n        kwargs[\"instance\"] = self.instance\n        return kwargs\n\n    def get_initial_get_param_spec(self) -> Dict[str, Optional[Callable[[str], Any]]]:\n        def list_arg(arg):\n            return arg.split(\",\")\n\n        spec = super().get_initial_get_param_spec()\n        spec.update({\n            \"module\": list_arg,\n            \"exercise\": list_arg,\n            \"submitter\": list_arg,\n            \"submitter_tag\": list_arg,\n        })\n        return spec\n\n    def form_valid(self, form: forms.BaseForm) -> HttpResponse:\n        exercises = get_exercises(form.cleaned_data)\n        submitters = get_submitters(form.cleaned_data)\n        existing_deviations = self.deviation_model.objects.filter(\n            exercise__in=exercises,\n            submitter__in=submitters,\n        )\n\n        if existing_deviations:\n            # Some deviations already existed. Use OverrideDeviationsView to\n            # confirm which ones the user wants to override. Store the form\n            # values in the current session, so they can be used afterwards.\n            self.success_url = self.deviation_model.get_override_url(self.instance)\n            self.request.session[self.session_key] = self.serialize_session_data(form.cleaned_data)\n        else:\n            self.success_url = self.deviation_model.get_list_url(self.instance)\n            for exercise in exercises:\n                for submitter in submitters:\n                    new_deviation = self.deviation_model(\n                        exercise=exercise,\n                        submitter=submitter,\n                        granter=self.request.user.userprofile,\n                    )\n                    new_deviation.update_by_form(form.cleaned_data)\n                    new_deviation.save()\n\n        return super().form_valid(form)\n\n    def serialize_session_data(self, form_data: Dict[str, Any]) -> Dict[str, Any]:\n        \"\"\"\n        Convert input form data into serializable values that can be stored in\n        the session cache.\n        \"\"\"\n        result = {}\n        for key in ('exercise', 'module', 'submitter', 'submitter_tag'):\n            result[key] = [i.id for i in form_data.get(key, [])]\n        return result\n\n\nclass OverrideDeviationsView(CourseInstanceMixin, BaseFormView):\n    access_mode = ACCESS.TEACHER\n    # form_class is not really used, but it is required by the FormView.\n    # The form contains only checkboxes and the user input is validated in\n    # the form_valid method. The form HTML is manually written in the template.\n    form_class = forms.Form\n    deviation_model: Type[SubmissionRuleDeviation]\n    session_key: str\n\n    def get_success_url(self) -> str:\n        return self.deviation_model.get_list_url(self.instance)\n\n    def get_common_objects(self) -> None:\n        super().get_common_objects()\n        self.session_data = self.deserialize_session_data(self.request.session[self.session_key])\n        self.exercises = get_exercises(self.session_data)\n        self.submitters = get_submitters(self.session_data)\n        self.existing_deviations = self.deviation_model.objects.filter(\n            exercise__in=self.exercises,\n            submitter__in=self.submitters,\n        )\n        self.deviation_groups = get_deviation_groups(self.existing_deviations)\n        self.note(\"session_data\", \"exercises\", \"submitters\", \"existing_deviations\", \"deviation_groups\")\n\n    def form_valid(self, form: forms.BaseForm) -> HttpResponse:\n        override_deviations = set()\n        deviation_list = self.request.POST.getlist('override')\n        for id_pair in deviation_list:\n            try:\n                submitter_id, exercise_id = id_pair.split('.')\n                submitter_id, exercise_id = int(submitter_id), int(exercise_id)\n                override_deviations.add((submitter_id, exercise_id))\n            except ValueError:\n                messages.error(self.request,\n                    format_lazy(\n                        _(\"INVALID_EXERCISE_OR_SUBMITTER_ID -- {id}\"),\n                        id=id_pair,\n                    )\n                )\n                continue\n\n        existing_deviations = {(d.submitter_id, d.exercise_id): d for d in self.existing_deviations}\n\n        for exercise in self.exercises:\n            for submitter in self.submitters:\n                existing_deviation = existing_deviations.get((submitter.id, exercise.id))\n                if existing_deviation is not None:\n                    if (submitter.id, exercise.id) in override_deviations:\n                        existing_deviation.granter = self.request.user.userprofile\n                        existing_deviation.update_by_form(self.session_data)\n                        existing_deviation.save()\n                else:\n                    new_deviation = self.deviation_model(\n                        exercise=exercise,\n                        submitter=submitter,\n                        granter=self.request.user.userprofile,\n                    )\n                    new_deviation.update_by_form(self.session_data)\n                    new_deviation.save()\n\n        del self.request.session[self.session_key]\n        return super().form_valid(form)\n\n    def deserialize_session_data(self, session_data: Dict[str, Any]) -> Dict[str, Any]:\n        \"\"\"\n        Convert serialized session data back into its original representation.\n        \"\"\"\n        result = {\n            'exercise': BaseExercise.objects.filter(id__in=session_data.get('exercise', [])),\n            'module': CourseModule.objects.filter(id__in=session_data.get('module', [])),\n            'submitter': UserProfile.objects.filter(id__in=session_data.get('submitter', [])),\n            'submitter_tag': UserTag.objects.filter(id__in=session_data.get('submitter_tag', [])),\n        }\n        return result\n\n\nclass RemoveDeviationsByIDView(CourseInstanceMixin, BaseRedirectView):\n    access_mode = ACCESS.TEACHER\n    deviation_model: Type[SubmissionRuleDeviation]\n\n    def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:\n        deviations = self.deviation_model.objects.filter(\n            id__in=request.POST.getlist(\"id\"),\n            exercise__course_module__course_instance=self.instance,\n        )\n        for deviation in deviations:\n            deviation.delete()\n        if is_ajax(request):\n            return HttpResponse(status=204)\n        return self.redirect(self.deviation_model.get_list_url(self.instance))\n\n\nclass RemoveDeviationsView(CourseInstanceMixin, BaseFormView):\n    access_mode = ACCESS.TEACHER\n    deviation_model: Type[SubmissionRuleDeviation]\n\n    def get_form_kwargs(self) -> Dict[str, Any]:\n        kwargs = super().get_form_kwargs()\n        kwargs[\"instance\"] = self.instance\n        return kwargs\n\n    def get_success_url(self) -> str:\n        return self.deviation_model.get_list_url(self.instance)\n\n    def form_valid(self, form: forms.BaseForm) -> HttpResponse:\n        number_of_removed = 0\n        deviations = self.deviation_model.objects.filter(\n            exercise__in=get_exercises(form.cleaned_data),\n            submitter__in=get_submitters(form.cleaned_data),\n        )\n        for deviation in deviations:\n            deviation.delete()\n            number_of_removed += 1\n        if number_of_removed == 0:\n            messages.warning(self.request, _(\"NOTHING_REMOVED\"))\n        else:\n            message = ngettext(\n                'REMOVED_DEVIATION -- {count}',\n                'REMOVED_DEVIATIONS -- {count}',\n                number_of_removed,\n            ).format(count=number_of_removed)\n            messages.info(self.request, message)\n        return super().form_valid(form)\n\n\ndef get_deviation_groups(\n        all_deviations: models.QuerySet[SubmissionRuleDeviation],\n        ) -> Iterable[Tuple[List[SubmissionRuleDeviation], bool, Optional[str]]]:\n    \"\"\"\n    Group the deviations by user and module.\n\n    Grouping condition: deviations can be grouped if the user has been\n    granted the same deviation (based on the `is_equal` method) for all\n    exercises in the module.\n\n    The returned tuples contain the following values:\n    1. List of deviations with the same user and module.\n    2. Boolean representing whether the deviations in the list can be\n    displayed as a group (i.e. the grouping condition is satisfied).\n    3. An id that uniquely identifies the group of deviations.\n    \"\"\"\n    # Find the number of exercises in each module.\n    course_instances = (\n        all_deviations\n        .values_list('exercise__course_module__course_instance', flat=True)\n        .distinct()\n    )\n    exercise_counts = (\n        BaseExercise.objects.filter(\n            course_module__course_instance__in=course_instances\n        )\n        .order_by()\n        .values('course_module_id')\n        .annotate(count=models.Count('*'))\n    )\n    exercise_count_by_module = {row['course_module_id']: row['count'] for row in exercise_counts}\n\n    ordered_deviations = (\n        all_deviations\n        .select_related(\n            'submitter', 'submitter__user',\n            'granter', 'granter__user',\n            'exercise', 'exercise__course_module',\n        )\n        # parent is prefetched because there may be multiple ancestors, and\n        # they are needed for building the deviation's URL.\n        .prefetch_related('exercise__parent')\n        .order_by('submitter', 'exercise__course_module')\n    )\n\n    deviation_groups = groupby(\n        ordered_deviations,\n        lambda obj: (obj.submitter, obj.exercise.course_module),\n    )\n    for (_submitter, module), deviations_iter in deviation_groups:\n        deviations = list(deviations_iter)\n        can_group = True\n        if len(deviations) < 2:\n            # Group must have at least 2 deviations.\n            can_group = False\n        else:\n            group_exercises = set()\n            # Check that the same deviation has been granted for all exercises.\n            for deviation in deviations:\n                if not deviation.is_groupable(deviations[0]):\n                    can_group = False\n                    break\n                group_exercises.add(deviation.exercise.id)\n            else:\n                if len(group_exercises) != exercise_count_by_module[module.id]:\n                    # The number of exercises that have deviations doesn't\n                    # match the number of exercises in the module, so there\n                    # are some exercises that don't have a deviation.\n                    can_group = False\n        group_id = f\"{deviations[0].submitter.id}.{module.id}\" if can_group else None\n        yield (deviations, can_group, group_id)\n\n\ndef get_exercises(form_data: Dict[str, Any]) -> models.QuerySet[BaseExercise]:\n    \"\"\"\n    Get the exercises that match the input form's `exercise` and `module`\n    fields.\n    \"\"\"\n    return BaseExercise.objects.filter(\n        models.Q(id__in=form_data.get('exercise', []))\n        | models.Q(course_module__in=form_data.get('module', []))\n    )\n\n\ndef get_submitters(form_data: Dict[str, Any]) -> models.QuerySet[UserProfile]:\n    \"\"\"\n    Get the submitters that match the input form's `submitter` and\n    `submitter_tag` fields.\n    \"\"\"\n    return UserProfile.objects.filter(\n        models.Q(id__in=form_data.get('submitter', []))\n        | models.Q(taggings__tag__in=form_data.get('submitter_tag', []))\n    ).distinct()\n", "path": "deviations/viewbase.py"}], "after_files": [{"content": "from itertools import groupby\nfrom typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Type\n\nfrom django.db import models\nfrom django.http import HttpRequest, HttpResponse\nfrom django.contrib import messages\nfrom django import forms\nfrom django.utils.text import format_lazy\nfrom django.utils.translation import gettext_lazy as _, ngettext\n\nfrom course.models import CourseModule, UserTag\nfrom course.viewbase import CourseInstanceMixin, CourseInstanceBaseView\nfrom deviations.models import SubmissionRuleDeviation\nfrom lib.helpers import is_ajax\nfrom lib.viewbase import BaseFormView, BaseRedirectView\nfrom authorization.permissions import ACCESS\nfrom exercise.models import BaseExercise\nfrom userprofile.models import UserProfile\n\n\nclass ListDeviationsView(CourseInstanceBaseView):\n    access_mode = ACCESS.TEACHER\n    deviation_model: Type[SubmissionRuleDeviation]\n\n    def get_common_objects(self) -> None:\n        super().get_common_objects()\n        all_deviations = self.deviation_model.objects.filter(\n            exercise__course_module__course_instance=self.instance\n        )\n        self.deviation_groups = get_deviation_groups(all_deviations)\n        self.note(\"deviation_groups\")\n\n\nclass AddDeviationsView(CourseInstanceMixin, BaseFormView):\n    access_mode = ACCESS.TEACHER\n    deviation_model: Type[SubmissionRuleDeviation]\n    session_key: str\n\n    def get_form_kwargs(self) -> Dict[str, Any]:\n        kwargs = super().get_form_kwargs()\n        kwargs[\"instance\"] = self.instance\n        return kwargs\n\n    def get_initial_get_param_spec(self) -> Dict[str, Optional[Callable[[str], Any]]]:\n        def list_arg(arg):\n            return arg.split(\",\")\n\n        spec = super().get_initial_get_param_spec()\n        spec.update({\n            \"module\": list_arg,\n            \"exercise\": list_arg,\n            \"submitter\": list_arg,\n            \"submitter_tag\": list_arg,\n        })\n        return spec\n\n    def form_valid(self, form: forms.BaseForm) -> HttpResponse:\n        exercises = get_exercises(form.cleaned_data)\n        submitters = get_submitters(form.cleaned_data)\n        existing_deviations = self.deviation_model.objects.filter(\n            exercise__in=exercises,\n            submitter__in=submitters,\n        )\n\n        if existing_deviations:\n            # Some deviations already existed. Use OverrideDeviationsView to\n            # confirm which ones the user wants to override. Store the form\n            # values in the current session, so they can be used afterwards.\n            self.success_url = self.deviation_model.get_override_url(self.instance)\n            self.request.session[self.session_key] = self.serialize_session_data(form.cleaned_data)\n        else:\n            self.success_url = self.deviation_model.get_list_url(self.instance)\n            for exercise in exercises:\n                for submitter in submitters:\n                    new_deviation = self.deviation_model(\n                        exercise=exercise,\n                        submitter=submitter,\n                        granter=self.request.user.userprofile,\n                    )\n                    new_deviation.update_by_form(form.cleaned_data)\n                    new_deviation.save()\n\n        return super().form_valid(form)\n\n    def serialize_session_data(self, form_data: Dict[str, Any]) -> Dict[str, Any]:\n        \"\"\"\n        Convert input form data into serializable values that can be stored in\n        the session cache.\n        \"\"\"\n        result = {}\n        for key in ('exercise', 'module', 'submitter', 'submitter_tag'):\n            result[key] = [i.id for i in form_data.get(key, [])]\n        return result\n\n\nclass OverrideDeviationsView(CourseInstanceMixin, BaseFormView):\n    access_mode = ACCESS.TEACHER\n    # form_class is not really used, but it is required by the FormView.\n    # The form contains only checkboxes and the user input is validated in\n    # the form_valid method. The form HTML is manually written in the template.\n    form_class = forms.Form\n    deviation_model: Type[SubmissionRuleDeviation]\n    session_key: str\n\n    def get_success_url(self) -> str:\n        return self.deviation_model.get_list_url(self.instance)\n\n    def get_common_objects(self) -> None:\n        super().get_common_objects()\n        self.session_data = self.deserialize_session_data(self.request.session[self.session_key])\n        self.exercises = get_exercises(self.session_data)\n        self.submitters = get_submitters(self.session_data)\n        self.existing_deviations = self.deviation_model.objects.filter(\n            exercise__in=self.exercises,\n            submitter__in=self.submitters,\n        )\n        self.deviation_groups = get_deviation_groups(self.existing_deviations)\n        self.note(\"session_data\", \"exercises\", \"submitters\", \"existing_deviations\", \"deviation_groups\")\n\n    def form_valid(self, form: forms.BaseForm) -> HttpResponse:\n        override_deviations = set()\n        deviation_list = self.request.POST.getlist('override')\n        for id_pair in deviation_list:\n            try:\n                submitter_id, exercise_id = id_pair.split('.')\n                submitter_id, exercise_id = int(submitter_id), int(exercise_id)\n                override_deviations.add((submitter_id, exercise_id))\n            except ValueError:\n                messages.error(self.request,\n                    format_lazy(\n                        _(\"INVALID_EXERCISE_OR_SUBMITTER_ID -- {id}\"),\n                        id=id_pair,\n                    )\n                )\n                continue\n\n        existing_deviations = {(d.submitter_id, d.exercise_id): d for d in self.existing_deviations}\n\n        for exercise in self.exercises:\n            for submitter in self.submitters:\n                existing_deviation = existing_deviations.get((submitter.id, exercise.id))\n                if existing_deviation is not None:\n                    if (submitter.id, exercise.id) in override_deviations:\n                        existing_deviation.granter = self.request.user.userprofile\n                        existing_deviation.update_by_form(self.session_data)\n                        existing_deviation.save()\n                else:\n                    new_deviation = self.deviation_model(\n                        exercise=exercise,\n                        submitter=submitter,\n                        granter=self.request.user.userprofile,\n                    )\n                    new_deviation.update_by_form(self.session_data)\n                    new_deviation.save()\n\n        del self.request.session[self.session_key]\n        return super().form_valid(form)\n\n    def deserialize_session_data(self, session_data: Dict[str, Any]) -> Dict[str, Any]:\n        \"\"\"\n        Convert serialized session data back into its original representation.\n        \"\"\"\n        result = {\n            'exercise': BaseExercise.objects.filter(id__in=session_data.get('exercise', [])),\n            'module': CourseModule.objects.filter(id__in=session_data.get('module', [])),\n            'submitter': UserProfile.objects.filter(id__in=session_data.get('submitter', [])),\n            'submitter_tag': UserTag.objects.filter(id__in=session_data.get('submitter_tag', [])),\n        }\n        return result\n\n\nclass RemoveDeviationsByIDView(CourseInstanceMixin, BaseRedirectView):\n    access_mode = ACCESS.TEACHER\n    deviation_model: Type[SubmissionRuleDeviation]\n\n    def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:\n        deviations = self.deviation_model.objects.filter(\n            id__in=request.POST.getlist(\"id\"),\n            exercise__course_module__course_instance=self.instance,\n        )\n        for deviation in deviations:\n            deviation.delete()\n        if is_ajax(request):\n            return HttpResponse(status=204)\n        return self.redirect(self.deviation_model.get_list_url(self.instance))\n\n\nclass RemoveDeviationsView(CourseInstanceMixin, BaseFormView):\n    access_mode = ACCESS.TEACHER\n    deviation_model: Type[SubmissionRuleDeviation]\n\n    def get_form_kwargs(self) -> Dict[str, Any]:\n        kwargs = super().get_form_kwargs()\n        kwargs[\"instance\"] = self.instance\n        return kwargs\n\n    def get_success_url(self) -> str:\n        return self.deviation_model.get_list_url(self.instance)\n\n    def form_valid(self, form: forms.BaseForm) -> HttpResponse:\n        number_of_removed = 0\n        deviations = self.deviation_model.objects.filter(\n            exercise__in=get_exercises(form.cleaned_data),\n            submitter__in=get_submitters(form.cleaned_data),\n        )\n        for deviation in deviations:\n            deviation.delete()\n            number_of_removed += 1\n        if number_of_removed == 0:\n            messages.warning(self.request, _(\"NOTHING_REMOVED\"))\n        else:\n            message = ngettext(\n                'REMOVED_DEVIATION -- {count}',\n                'REMOVED_DEVIATIONS -- {count}',\n                number_of_removed,\n            ).format(count=number_of_removed)\n            messages.info(self.request, message)\n        return super().form_valid(form)\n\n\n# pylint: disable-next=too-many-locals\ndef get_deviation_groups(\n        all_deviations: models.QuerySet[SubmissionRuleDeviation],\n        ) -> Iterable[Tuple[List[SubmissionRuleDeviation], bool, Optional[str]]]:\n    \"\"\"\n    Group the deviations by user and module.\n\n    Grouping condition: deviations can be grouped if the user has been\n    granted the same deviation (based on the `is_equal` method) for all\n    exercises in the module.\n\n    The returned tuples contain the following values:\n    1. List of deviations with the same user and module.\n    2. Boolean representing whether the deviations in the list can be\n    displayed as a group (i.e. the grouping condition is satisfied).\n    3. An id that uniquely identifies the group of deviations.\n    \"\"\"\n    # Find the number of exercises in each module.\n    course_instances = (\n        all_deviations\n        .values_list('exercise__course_module__course_instance', flat=True)\n        .distinct()\n    )\n    exercise_counts = (\n        BaseExercise.objects.filter(\n            course_module__course_instance__in=course_instances\n        )\n        .order_by()\n        .values('course_module_id')\n        .annotate(count=models.Count('*'))\n    )\n    exercise_count_by_module = {row['course_module_id']: row['count'] for row in exercise_counts}\n\n    ordered_deviations = (\n        all_deviations\n        .select_related(\n            'submitter', 'submitter__user',\n            'granter', 'granter__user',\n            'exercise', 'exercise__course_module',\n        )\n        # parent is prefetched because there may be multiple ancestors, and\n        # they are needed for building the deviation's URL.\n        .prefetch_related('exercise__parent')\n        .order_by('submitter', 'exercise__course_module')\n    )\n\n    deviation_groups = groupby(\n        ordered_deviations,\n        lambda obj: (obj.submitter, obj.exercise.course_module),\n    )\n    for (_submitter, module), deviations_iter in deviation_groups:\n        deviations = list(deviations_iter)\n        can_group = True\n        show_granter = True\n        if len(deviations) < 2:\n            # Group must have at least 2 deviations.\n            can_group = False\n        else:\n            group_exercises = set()\n            # Check that the same deviation has been granted for all exercises.\n            first_granter = deviations[0].granter.id\n            for deviation in deviations:\n                if not deviation.is_groupable(deviations[0]):\n                    can_group = False\n                    if not show_granter:\n                        break\n                if deviation.granter.id != first_granter:\n                    show_granter = False\n                    if not can_group:\n                        break\n                group_exercises.add(deviation.exercise.id)\n            else:\n                if len(group_exercises) != exercise_count_by_module[module.id]:\n                    # The number of exercises that have deviations doesn't\n                    # match the number of exercises in the module, so there\n                    # are some exercises that don't have a deviation.\n                    can_group = False\n        group_id = f\"{deviations[0].submitter.id}.{module.id}\" if can_group else None\n        yield (deviations, can_group, group_id, show_granter)\n\n\ndef get_exercises(form_data: Dict[str, Any]) -> models.QuerySet[BaseExercise]:\n    \"\"\"\n    Get the exercises that match the input form's `exercise` and `module`\n    fields.\n    \"\"\"\n    return BaseExercise.objects.filter(\n        models.Q(id__in=form_data.get('exercise', []))\n        | models.Q(course_module__in=form_data.get('module', []))\n    )\n\n\ndef get_submitters(form_data: Dict[str, Any]) -> models.QuerySet[UserProfile]:\n    \"\"\"\n    Get the submitters that match the input form's `submitter` and\n    `submitter_tag` fields.\n    \"\"\"\n    return UserProfile.objects.filter(\n        models.Q(id__in=form_data.get('submitter', []))\n        | models.Q(taggings__tag__in=form_data.get('submitter_tag', []))\n    ).distinct()\n", "path": "deviations/viewbase.py"}]}