Repository: e-valuation/EvaP

Issue:
Locked questionnaires failing in editor form
#1445 introduced locked questionnaires. However, they are not dealt with correctly in the evaluation editor form. When initially opening the form, the locked questionnaires are correctly selected but are not handled correctly when saving the form.

Steps to reproduce:
1. As manager, assign a locked questionnaire as the only general questionnaire for an evaluation.
2. Enable the evaluation for editor review.
3. As editor, open the evaluation form and try to save it. Saving will fail with an error for the field "General questionnaires" ("This field is required.").

The locked questionnaire should count as a selected questionnaire and the form should be saved.
A test should be added for this use case.


Discussion:

Resolving patch:
diff --git a/evap/contributor/forms.py b/evap/contributor/forms.py
--- a/evap/contributor/forms.py
+++ b/evap/contributor/forms.py
@@ -15,7 +15,7 @@
 
 
 class EvaluationForm(forms.ModelForm):
-    general_questionnaires = forms.ModelMultipleChoiceField(queryset=None, widget=CheckboxSelectMultiple, label=_("General questionnaires"))
+    general_questionnaires = forms.ModelMultipleChoiceField(queryset=None, required=False, widget=CheckboxSelectMultiple, label=_("General questionnaires"))
     course = forms.ModelChoiceField(Course.objects.all(), disabled=True, required=False, widget=forms.HiddenInput())
     name_de_field = forms.CharField(label=_("Name (German)"), disabled=True, required=False)
     name_en_field = forms.CharField(label=_("Name (English)"), disabled=True, required=False)
@@ -64,10 +64,14 @@ def clean_vote_end_date(self):
 
     def clean_general_questionnaires(self):
         # Ensure all locked questionnaires still have the same status (included or not)
-        locked_qs = self.fields['general_questionnaires'].queryset.filter(is_locked=True)
+        not_locked = []
+        if self.cleaned_data.get('general_questionnaires'):
+            not_locked = list(self.cleaned_data.get('general_questionnaires').filter(is_locked=False))
 
-        not_locked = [q for q in self.cleaned_data.get('general_questionnaires') if q not in locked_qs]
-        locked = [q.pk for q in self.instance.general_contribution.questionnaires.filter(is_locked=True)]
+        locked = list(self.instance.general_contribution.questionnaires.filter(is_locked=True))
+
+        if not not_locked + locked:
+            self.add_error("general_questionnaires", _("At least one questionnaire must be selected."))
 
         return not_locked + locked
 


Test patch:
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
@@ -3,10 +3,10 @@
 from django_webtest import WebTest
 from model_bakery import baker
 
-from evap.evaluation.models import Evaluation, UserProfile, Contribution
+from evap.evaluation.models import Evaluation, UserProfile, Contribution, Questionnaire, Course
 from evap.evaluation.tests.tools import WebTestWith200Check, create_evaluation_with_responsible_and_editor
 
-TESTING_COURSE_ID = 2
+TESTING_EVALUATION_ID = 2
 
 
 class TestContributorDirectDelegationView(WebTest):
@@ -92,15 +92,15 @@ def test_save_settings(self):
 
 
 class TestContributorEvaluationView(WebTestWith200Check):
-    url = '/contributor/evaluation/%s' % TESTING_COURSE_ID
+    url = '/contributor/evaluation/%s' % TESTING_EVALUATION_ID
     test_users = ['editor@institution.example.com', 'responsible@institution.example.com']
 
     @classmethod
     def setUpTestData(cls):
-        create_evaluation_with_responsible_and_editor(evaluation_id=TESTING_COURSE_ID)
+        create_evaluation_with_responsible_and_editor(evaluation_id=TESTING_EVALUATION_ID)
 
     def setUp(self):
-        self.evaluation = Evaluation.objects.get(pk=TESTING_COURSE_ID)
+        self.evaluation = Evaluation.objects.get(pk=TESTING_EVALUATION_ID)
 
     def test_wrong_state(self):
         self.evaluation.revert_to_new()
@@ -117,15 +117,15 @@ def test_information_message(self):
 
 
 class TestContributorEvaluationPreviewView(WebTestWith200Check):
-    url = '/contributor/evaluation/%s/preview' % TESTING_COURSE_ID
+    url = '/contributor/evaluation/%s/preview' % TESTING_EVALUATION_ID
     test_users = ['editor@institution.example.com', 'responsible@institution.example.com']
 
     @classmethod
     def setUpTestData(cls):
-        create_evaluation_with_responsible_and_editor(evaluation_id=TESTING_COURSE_ID)
+        create_evaluation_with_responsible_and_editor(evaluation_id=TESTING_EVALUATION_ID)
 
     def setUp(self):
-        self.evaluation = Evaluation.objects.get(pk=TESTING_COURSE_ID)
+        self.evaluation = Evaluation.objects.get(pk=TESTING_EVALUATION_ID)
 
     def test_wrong_state(self):
         self.evaluation.revert_to_new()
@@ -134,21 +134,21 @@ def test_wrong_state(self):
 
 
 class TestContributorEvaluationEditView(WebTest):
-    url = '/contributor/evaluation/%s/edit' % TESTING_COURSE_ID
+    url = '/contributor/evaluation/%s/edit' % TESTING_EVALUATION_ID
 
     @classmethod
     def setUpTestData(cls):
-        create_evaluation_with_responsible_and_editor(evaluation_id=TESTING_COURSE_ID)
+        create_evaluation_with_responsible_and_editor(evaluation_id=TESTING_EVALUATION_ID)
 
     def setUp(self):
-        self.evaluation = Evaluation.objects.get(pk=TESTING_COURSE_ID)
+        self.evaluation = Evaluation.objects.get(pk=TESTING_EVALUATION_ID)
 
     def test_not_authenticated(self):
         """
             Asserts that an unauthorized user gets redirected to the login page.
         """
         response = self.app.get(self.url)
-        self.assertRedirects(response, '/?next=/contributor/evaluation/%s/edit' % TESTING_COURSE_ID)
+        self.assertRedirects(response, '/?next=/contributor/evaluation/%s/edit' % TESTING_EVALUATION_ID)
 
     def test_wrong_usergroup(self):
         """
@@ -190,6 +190,33 @@ def test_contributor_evaluation_edit(self):
         response = form.submit(expect_errors=True)
         self.assertEqual(response.status_code, 403)
 
+    def test_single_locked_questionnaire(self):
+        locked_questionnaire = baker.make(
+            Questionnaire,
+            type=Questionnaire.Type.TOP,
+            is_locked=True,
+            visibility=Questionnaire.Visibility.EDITORS,
+        )
+        responsible = UserProfile.objects.get(email='responsible@institution.example.com')
+        evaluation = baker.make(
+            Evaluation,
+            course=baker.make(Course, responsibles=[responsible]),
+            state='prepared',
+            pk=TESTING_EVALUATION_ID+1
+        )
+        evaluation.general_contribution.questionnaires.set([locked_questionnaire])
+
+        page = self.app.get(f'/contributor/evaluation/{evaluation.pk}/edit', user=responsible, status=200)
+        form = page.forms['evaluation-form']
+
+        # see https://github.com/Pylons/webtest/issues/138
+        for name_field_tuple in form.field_order[:]:
+            if 'disabled' in name_field_tuple[1].attrs:
+                form.field_order.remove(name_field_tuple)
+
+        response = form.submit(name='operation', value='save')
+        self.assertIn("Successfully updated evaluation", response.follow())
+
     def test_contributor_evaluation_edit_preview(self):
         """
             Asserts that the preview button either renders a preview or shows an error.