Repository: e-valuation/EvaP

Issue:
Make participants editable by editors
Editors are currently not able to change participants of an evaluation and have to send a message asking for changes instead. This results in a lot of work for the evaluation team. Since recently, all changes are logged, so editors can be allowed to change the participants themselves, and staff users can see what changes have been made.

This should be done as follows;
- The list of participants should become an editable form field just like in the staff form and should be placed at the same position as in the staff form instead of at the end of the page. 
- The field (both for editors and staff) should be changed to contain exactly one person per line (by changing the select2 elements' CSS).
- If the log contains participant changes done by users who are not staff and the evaluation was not yet approved by a staff user, the participant field in the staff form should show a warning (e.g., by having a yellow border).


Discussion:
That's pretty cool. And a little obvious in hindsight again :D 

It does feel a little weird to me to use the logging component for business logic. An alternative could be a boolean flag on the evaluation, like "participations_modified". This might even be simpler and more robust in the end than searching those JSONs for participation changes. The flag could also be cleared easily if need be (perhaps when sending the evaluation back to editors?), that would be a lot harder with logging entries.

Or maybe display a filtered log with all the entries that should be reviewed somewhere above the save buttons?

If not, I suppose marking (e.g. coloring) log entries that should be reviewed in the log below the save buttons could help so staff users have to search less if the log gets long.

Or maybe move the entire log above the buttons, but by default only show entries that should be reviewed, and provide a "Show full log" button?

:) 
I'm fine with both using the flag and coloring log entries. For the time being, we could also omit the warning part, because another larger issue with logging/highlighting refactoring for the evaluation form will be added soon.
Making the participants editable is especially important and can be implemented even if the other requirements have not yet been fully specified.

Resolving patch:
diff --git a/evap/contributor/forms.py b/evap/contributor/forms.py
--- a/evap/contributor/forms.py
+++ b/evap/contributor/forms.py
@@ -5,7 +5,7 @@
 from django.forms.widgets import CheckboxSelectMultiple
 from django.utils.translation import gettext_lazy as _
 
-from evap.evaluation.forms import UserModelChoiceField
+from evap.evaluation.forms import UserModelChoiceField, UserModelMultipleChoiceField
 from evap.evaluation.models import Course, Evaluation, Questionnaire, UserProfile
 from evap.evaluation.tools import vote_end_datetime
 from evap.staff.forms import ContributionForm
@@ -26,9 +26,13 @@ class Meta:
             "name_en_field",
             "vote_start_datetime",
             "vote_end_date",
+            "participants",
             "general_questionnaires",
             "course",
         )
+        field_classes = {
+            "participants": UserModelMultipleChoiceField,
+        }
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
@@ -45,6 +49,11 @@ def __init__(self, *args, **kwargs):
         self.fields["vote_start_datetime"].localize = True
         self.fields["vote_end_date"].localize = True
 
+        queryset = UserProfile.objects.exclude(is_active=False)
+        if self.instance.pk is not None:
+            queryset = (queryset | self.instance.participants.all()).distinct()
+        self.fields["participants"].queryset = queryset
+
         if self.instance.general_contribution:
             self.fields["general_questionnaires"].initial = [
                 q.pk for q in self.instance.general_contribution.questionnaires.all()
diff --git a/evap/evaluation/forms.py b/evap/evaluation/forms.py
--- a/evap/evaluation/forms.py
+++ b/evap/evaluation/forms.py
@@ -97,6 +97,8 @@ def label_from_instance(self, obj):
 
 
 class UserModelMultipleChoiceField(forms.ModelMultipleChoiceField):
+    widget = forms.SelectMultiple(attrs={"data-selection-css-class": "user-multi-select"})
+
     def label_from_instance(self, obj):
         return obj.full_name_with_additional_info
 
diff --git a/evap/staff/forms.py b/evap/staff/forms.py
--- a/evap/staff/forms.py
+++ b/evap/staff/forms.py
@@ -386,7 +386,10 @@ def __init__(self, *args, **kwargs):
             Questionnaire.objects.general_questionnaires().filter(visible_questionnaires).distinct()
         )
 
-        self.fields["participants"].queryset = UserProfile.objects.exclude(is_active=False)
+        queryset = UserProfile.objects.exclude(is_active=False)
+        if self.instance.pk is not None:
+            queryset = (queryset | self.instance.participants.all()).distinct()
+        self.fields["participants"].queryset = queryset
 
         if self.instance.general_contribution:
             self.fields["general_questionnaires"].initial = [


Test patch:
diff --git a/evap/contributor/tests/test_forms.py b/evap/contributor/tests/test_forms.py
--- a/evap/contributor/tests/test_forms.py
+++ b/evap/contributor/tests/test_forms.py
@@ -3,7 +3,7 @@
 from model_bakery import baker
 
 from evap.contributor.forms import EditorContributionForm, EvaluationForm
-from evap.evaluation.models import Contribution, Evaluation, Questionnaire, UserProfile
+from evap.evaluation.models import Contribution, Degree, Evaluation, Questionnaire, UserProfile
 from evap.evaluation.tests.tools import WebTest, get_form_data_from_instance
 from evap.staff.forms import ContributionFormSet
 
@@ -21,6 +21,24 @@ def test_fields_disabled_when_editors_disallowed_to_edit(self):
         form = EvaluationForm(instance=evaluation)
         self.assertTrue(all(form.fields[field].disabled for field in form.fields))
 
+    def test_edit_participants(self):
+        student = baker.make(UserProfile)
+        evaluation = baker.make(Evaluation, course__degrees=[baker.make(Degree)], participants=[student])
+        evaluation.general_contribution.questionnaires.set([baker.make(Questionnaire)])
+
+        form_data = get_form_data_from_instance(EvaluationForm, evaluation)
+        form = EvaluationForm(form_data, instance=evaluation)
+        self.assertEqual(len(form["participants"].initial), 1)
+
+        form_data["participants"].remove(student.pk)
+        EvaluationForm(form_data, instance=evaluation).save()
+        self.assertEqual(evaluation.num_participants, 0)
+
+        form_data["participants"].append(student.pk)
+        EvaluationForm(form_data, instance=evaluation).save()
+        del evaluation.num_participants  # discard cached property
+        self.assertEqual(evaluation.num_participants, 1)
+
 
 class ContributionFormsetTests(TestCase):
     def test_managers_only(self):
diff --git a/evap/contributor/tests/test_views.py b/evap/contributor/tests/test_views.py
--- a/evap/contributor/tests/test_views.py
+++ b/evap/contributor/tests/test_views.py
@@ -244,7 +244,7 @@ def test_contact_modal_escape(self):
         self.evaluation.save()
         page = self.app.get(self.url, user=self.responsible, status=200)
 
-        self.assertIn("changeParticipantRequestModalLabel", page)
+        self.assertIn("changeEvaluationRequestModalLabel", page)
 
         self.assertNotIn("Adam &amp;amp; Eve", page)
         self.assertIn("Adam &amp; Eve", page)
@@ -258,3 +258,16 @@ def test_information_message(self):
             "Please review the evaluation's details below, add all contributors and select suitable questionnaires. "
             "Once everything is okay, please approve the evaluation on the bottom of the page.",
         )
+
+    def test_display_request_buttons(self):
+        self.evaluation.allow_editors_to_edit = False
+        self.evaluation.save()
+        page = self.app.get(self.url, user=self.responsible)
+        self.assertEqual(page.body.decode().count("Request changes"), 1)
+        self.assertEqual(page.body.decode().count("Request creation of new account"), 1)
+
+        self.evaluation.allow_editors_to_edit = True
+        self.evaluation.save()
+        page = self.app.get(self.url, user=self.responsible)
+        self.assertEqual(page.body.decode().count("Request changes"), 0)
+        self.assertEqual(page.body.decode().count("Request creation of new account"), 2)
diff --git a/evap/staff/tests/test_forms.py b/evap/staff/tests/test_forms.py
--- a/evap/staff/tests/test_forms.py
+++ b/evap/staff/tests/test_forms.py
@@ -954,6 +954,29 @@ def test_unused_questionnaire_visibility(self):
         form = EvaluationForm(instance=evaluation, semester=evaluation.course.semester)
         self.assertIn(questionnaire, form.fields["general_questionnaires"].queryset)
 
+    def test_inactive_participants_remain(self):
+        student = baker.make(UserProfile, is_active=False)
+        evaluation = baker.make(Evaluation, course__degrees=[baker.make(Degree)], participants=[student])
+
+        form_data = get_form_data_from_instance(EvaluationForm, evaluation)
+        form = EvaluationForm(form_data, instance=evaluation)
+        self.assertEqual(len(form["participants"]), 1)
+
+    def test_inactive_participants_not_in_queryset(self):
+        evaluation = baker.make(Evaluation, course__degrees=[baker.make(Degree)])
+
+        form_data = get_form_data_from_instance(EvaluationForm, evaluation)
+        form = EvaluationForm(form_data, instance=evaluation)
+        self.assertEqual(form.fields["participants"].queryset.count(), 0)
+
+        baker.make(UserProfile, is_active=True)
+        form = EvaluationForm(form_data, instance=evaluation)
+        self.assertEqual(form.fields["participants"].queryset.count(), 1)
+
+        baker.make(UserProfile, is_active=False)
+        form = EvaluationForm(form_data, instance=evaluation)
+        self.assertEqual(form.fields["participants"].queryset.count(), 1)
+
 
 class EvaluationCopyFormTests(TestCase):
     @classmethod