Repository: e-valuation/EvaP

Issue:
Text results are never published
In `student/views.py:vote` voters are added by `course.voters.through.objects.get_or_create`. This doesn't call the `m2m_changed` signal in `evaluation/models.py:voters_changed`. As a consequence, `can_publish_text_results` is not set to `True` by adding the second voter to a course as it should be.

Our tests didn't show the issue because we're adding voters by `course.voters.add` there.


Discussion:

Resolving patch:
diff --git a/evap/evaluation/models.py b/evap/evaluation/models.py
--- a/evap/evaluation/models.py
+++ b/evap/evaluation/models.py
@@ -582,14 +582,6 @@ def update_courses(cls):
         logger.info("update_courses finished.")
 
 
-@receiver(models.signals.m2m_changed, sender=Course.voters.through)
-def voters_changed(instance, action, reverse, **kwargs):
-    if not reverse and action == 'post_add':
-        if not instance.can_publish_text_results and instance.voters.count() >= 2:
-            instance.can_publish_text_results = True
-            instance.save()
-
-
 @receiver(post_transition, sender=Course)
 def log_state_transition(sender, instance, name, source, target, **kwargs):
     logger.info('Course "{}" (id {}) moved from state "{}" to state "{}", caused by transition "{}".'.format(instance, instance.pk, source, target, name))
diff --git a/evap/student/views.py b/evap/student/views.py
--- a/evap/student/views.py
+++ b/evap/student/views.py
@@ -139,8 +139,8 @@ def vote(request, course_id):
                             answer_counter.save()
 
         if not course.can_publish_text_results:
-            # enable text result publishing if first user confirmed that publishing is okay
-            if request.POST.get('text_results_publish_confirmation_top') == 'on' or request.POST.get('text_results_publish_confirmation_bottom') == 'on':
+            # enable text result publishing if first user confirmed that publishing is okay or second user voted
+            if request.POST.get('text_results_publish_confirmation_top') == 'on' or request.POST.get('text_results_publish_confirmation_bottom') == 'on' or course.voters.count() >= 2:
                 course.can_publish_text_results = True
                 course.save()
 


Test patch:
diff --git a/evap/evaluation/tests/test_models.py b/evap/evaluation/tests/test_models.py
--- a/evap/evaluation/tests/test_models.py
+++ b/evap/evaluation/tests/test_models.py
@@ -10,11 +10,12 @@
 
 from evap.evaluation.models import (Contribution, Course, CourseType, EmailTemplate, NotArchiveable, Question,
                                     Questionnaire, RatingAnswerCounter, Semester, TextAnswer, UserProfile)
+from evap.evaluation.tests.tools import WebTest
 from evap.results.tools import calculate_average_distribution
 
 
 @override_settings(EVALUATION_END_OFFSET_HOURS=0)
-class TestCourses(TestCase):
+class TestCourses(WebTest):
     def test_approved_to_in_evaluation(self):
         course = mommy.make(Course, state='approved', vote_start_datetime=datetime.now())
 
@@ -188,19 +189,22 @@ def test_single_result_can_be_deleted_only_in_reviewed(self):
     def test_adding_second_voter_sets_can_publish_text_results_to_true(self):
         student1 = mommy.make(UserProfile)
         student2 = mommy.make(UserProfile)
-        course = mommy.make(Course, participants=[student1, student2], voters=[student1])
+        course = mommy.make(Course, participants=[student1, student2], voters=[student1], state="in_evaluation")
         course.save()
+        top_course_questionnaire = mommy.make(Questionnaire, type=Questionnaire.TOP)
+        mommy.make(Question, questionnaire=top_course_questionnaire, type="L")
+        course.general_contribution.questionnaires.set([top_course_questionnaire])
 
         self.assertFalse(course.can_publish_text_results)
 
-        course.voters.add(student2)
+        self.let_user_vote_for_course(student2, course)
         course = Course.objects.get(pk=course.pk)
 
         self.assertTrue(course.can_publish_text_results)
 
     def test_text_answers_get_deleted_if_they_cannot_be_published(self):
         student = mommy.make(UserProfile)
-        course = mommy.make(Course, state='reviewed', participants=[student], voters=[student])
+        course = mommy.make(Course, state='reviewed', participants=[student], voters=[student], can_publish_text_results=False)
         questionnaire = mommy.make(Questionnaire, type=Questionnaire.TOP)
         question = mommy.make(Question, type="T", questionnaire=questionnaire)
         course.general_contribution.questionnaires.set([questionnaire])
@@ -213,7 +217,7 @@ def test_text_answers_get_deleted_if_they_cannot_be_published(self):
     def test_text_answers_do_not_get_deleted_if_they_can_be_published(self):
         student = mommy.make(UserProfile)
         student2 = mommy.make(UserProfile)
-        course = mommy.make(Course, state='reviewed', participants=[student, student2], voters=[student, student2])
+        course = mommy.make(Course, state='reviewed', participants=[student, student2], voters=[student, student2], can_publish_text_results=True)
         questionnaire = mommy.make(Questionnaire, type=Questionnaire.TOP)
         question = mommy.make(Question, type="T", questionnaire=questionnaire)
         course.general_contribution.questionnaires.set([questionnaire])
@@ -226,7 +230,7 @@ def test_text_answers_do_not_get_deleted_if_they_can_be_published(self):
     def test_hidden_text_answers_get_deleted_on_publish(self):
         student = mommy.make(UserProfile)
         student2 = mommy.make(UserProfile)
-        course = mommy.make(Course, state='reviewed', participants=[student, student2], voters=[student, student2])
+        course = mommy.make(Course, state='reviewed', participants=[student, student2], voters=[student, student2], can_publish_text_results=True)
         questionnaire = mommy.make(Questionnaire, type=Questionnaire.TOP)
         question = mommy.make(Question, type="T", questionnaire=questionnaire)
         course.general_contribution.questionnaires.set([questionnaire])
@@ -242,7 +246,7 @@ def test_hidden_text_answers_get_deleted_on_publish(self):
     def test_original_text_answers_get_deleted_on_publish(self):
         student = mommy.make(UserProfile)
         student2 = mommy.make(UserProfile)
-        course = mommy.make(Course, state='reviewed', participants=[student, student2], voters=[student, student2])
+        course = mommy.make(Course, state='reviewed', participants=[student, student2], voters=[student, student2], can_publish_text_results=True)
         questionnaire = mommy.make(Questionnaire, type=Questionnaire.TOP)
         question = mommy.make(Question, type="T", questionnaire=questionnaire)
         course.general_contribution.questionnaires.set([questionnaire])
diff --git a/evap/evaluation/tests/tools.py b/evap/evaluation/tests/tools.py
--- a/evap/evaluation/tests/tools.py
+++ b/evap/evaluation/tests/tools.py
@@ -5,6 +5,7 @@
 from webtest import AppError
 
 from evap.evaluation.models import Contribution, Course, UserProfile, Questionnaire, Degree
+from evap.student.tools import question_id
 
 
 def to_querydict(dictionary):
@@ -53,6 +54,19 @@ def get_submit_assert_200(self, url, user):
         self.assertEqual(response.status_code, 200, 'url "{}" failed with user "{}"'.format(url, user))
         return response
 
+    def let_user_vote_for_course(self, user, course):
+        url = '/student/vote/{}'.format(course.id)
+        page = self.get_assert_200(url, user=user)
+        form = page.forms["student-vote-form"]
+        for contribution in course.contributions.all().prefetch_related("questionnaires", "questionnaires__question_set"):
+            for questionnaire in contribution.questionnaires.all():
+                for question in questionnaire.question_set.all():
+                    if question.type == "T":
+                        form[question_id(contribution, questionnaire, question)] = "Lorem ispum"
+                    elif question.type in ["L", "G", "P", "N"]:
+                        form[question_id(contribution, questionnaire, question)] = 1
+        form.submit()
+
 
 class ViewTest(WebTest):
     url = "/"
diff --git a/evap/results/tests/test_views.py b/evap/results/tests/test_views.py
--- a/evap/results/tests/test_views.py
+++ b/evap/results/tests/test_views.py
@@ -162,25 +162,24 @@ def setUpTestData(cls):
         cls.semester = mommy.make(Semester, id=2)
         mommy.make(UserProfile, username='staff', groups=[Group.objects.get(name='Staff')], email="staff@institution.example.com")
         responsible = mommy.make(UserProfile, username='responsible')
-        student1 = mommy.make(UserProfile, username='student')
+        cls.student1 = mommy.make(UserProfile, username='student')
         cls.student2 = mommy.make(UserProfile)
+        students = mommy.make(UserProfile, _quantity=10)
+        students.extend([cls.student1, cls.student2])
 
-        cls.course = mommy.make(Course, id=22, state='published', semester=cls.semester, participants=[student1, cls.student2], voters=[student1])
+        cls.course = mommy.make(Course, id=22, state='in_evaluation', semester=cls.semester, participants=students)
         questionnaire = mommy.make(Questionnaire)
         cls.question_grade = mommy.make(Question, questionnaire=questionnaire, type="G")
         question_likert = mommy.make(Question, questionnaire=questionnaire, type="L")
         cls.course.general_contribution.questionnaires.set([questionnaire])
         cls.responsible_contribution = mommy.make(Contribution, contributor=responsible, course=cls.course, questionnaires=[questionnaire])
 
-        mommy.make(RatingAnswerCounter, question=cls.question_grade, contribution=cls.responsible_contribution, answer=1, count=1)
-        mommy.make(RatingAnswerCounter, question=question_likert, contribution=cls.responsible_contribution, answer=3, count=1)
-        cls.course.general_contribution.questionnaires.set([questionnaire])
-        mommy.make(RatingAnswerCounter, question=question_likert, contribution=cls.course.general_contribution, answer=5, count=1)
+    def setUp(self):
+        self.course = Course.objects.get(pk=self.course.pk)
 
-    def helper_test_answer_visibility(self, username, expect_page_not_visible_first=False):
-        caches["results"].clear()
-        page = self.app.get("/results/semester/2/course/22", user=username, expect_errors=expect_page_not_visible_first)
-        if expect_page_not_visible_first:
+    def helper_test_answer_visibility_one_voter(self, username, expect_page_not_visible=False):
+        page = self.app.get("/results/semester/2/course/22", user=username, expect_errors=expect_page_not_visible)
+        if expect_page_not_visible:
             self.assertEqual(page.status_code, 403)
         else:
             self.assertEqual(page.status_code, 200)
@@ -191,10 +190,7 @@ def helper_test_answer_visibility(self, username, expect_page_not_visible_first=
             number_of_disabled_grade_badges = str(page).count("grade-bg-result-bar text-center grade-bg-disabled")
             self.assertEqual(number_of_disabled_grade_badges, 5)
 
-        # add additional voter
-        self.course.voters.add(self.student2)
-
-        caches["results"].clear()
+    def helper_test_answer_visibility_two_voters(self, username):
         page = self.app.get("/results/semester/2/course/22", user=username)
         number_of_grade_badges = str(page).count("grade-bg-result-bar text-center")
         self.assertEqual(number_of_grade_badges, 5)  # 1 course overview and 4 questions
@@ -203,13 +199,30 @@ def helper_test_answer_visibility(self, username, expect_page_not_visible_first=
         number_of_disabled_grade_badges = str(page).count("grade-bg-result-bar text-center grade-bg-disabled")
         self.assertEqual(number_of_disabled_grade_badges, 1)
 
-        # remove additional voter
-        self.course.voters.remove(self.student2)
-
-    def test_answer_visibility(self):
-        self.helper_test_answer_visibility("staff")
-        self.helper_test_answer_visibility("responsible")
-        self.helper_test_answer_visibility("student", expect_page_not_visible_first=True)
+    def test_answer_visibility_one_voter(self):
+        self.let_user_vote_for_course(self.student1, self.course)
+        self.course.evaluation_end()
+        self.course.review_finished()
+        self.course.publish()
+        self.course.save()
+        self.assertEqual(self.course.voters.count(), 1)
+        self.helper_test_answer_visibility_one_voter("staff")
+        self.course = Course.objects.get(id=self.course.id)
+        self.helper_test_answer_visibility_one_voter("responsible")
+        self.helper_test_answer_visibility_one_voter("student", expect_page_not_visible=True)
+
+    def test_answer_visibility_two_voters(self):
+        self.let_user_vote_for_course(self.student1, self.course)
+        self.let_user_vote_for_course(self.student2, self.course)
+        self.course.evaluation_end()
+        self.course.review_finished()
+        self.course.publish()
+        self.course.save()
+        self.assertEqual(self.course.voters.count(), 2)
+
+        self.helper_test_answer_visibility_two_voters("staff")
+        self.helper_test_answer_visibility_two_voters("responsible")
+        self.helper_test_answer_visibility_two_voters("student")
 
 
 class TestResultsSemesterCourseDetailViewPrivateCourse(WebTest):
diff --git a/evap/staff/tests/test_views.py b/evap/staff/tests/test_views.py
--- a/evap/staff/tests/test_views.py
+++ b/evap/staff/tests/test_views.py
@@ -1243,7 +1243,10 @@ def setUpTestData(cls):
         semester = mommy.make(Semester, pk=1)
         student1 = mommy.make(UserProfile)
         cls.student2 = mommy.make(UserProfile)
-        cls.course = mommy.make(Course, pk=1, semester=semester, participants=[student1, cls.student2], voters=[student1])
+        cls.course = mommy.make(Course, pk=1, semester=semester, participants=[student1, cls.student2], voters=[student1], state="in_evaluation")
+        top_course_questionnaire = mommy.make(Questionnaire, type=Questionnaire.TOP)
+        mommy.make(Question, questionnaire=top_course_questionnaire, type="L")
+        cls.course.general_contribution.questionnaires.set([top_course_questionnaire])
 
     def test_comments_showing_up(self):
         questionnaire = mommy.make(Questionnaire)
@@ -1256,7 +1259,7 @@ def test_comments_showing_up(self):
         self.get_assert_403(self.url, user='staff')
 
         # add additional voter
-        self.course.voters.add(self.student2)
+        self.let_user_vote_for_course(self.student2, self.course)
 
         # now it should work
         page = self.get_assert_200(self.url, user='staff')
@@ -1272,7 +1275,10 @@ def setUpTestData(cls):
         semester = mommy.make(Semester, pk=1)
         student1 = mommy.make(UserProfile)
         cls.student2 = mommy.make(UserProfile)
-        cls.course = mommy.make(Course, pk=1, semester=semester, participants=[student1, cls.student2], voters=[student1])
+        cls.course = mommy.make(Course, pk=1, semester=semester, participants=[student1, cls.student2], voters=[student1], state="in_evaluation")
+        top_course_questionnaire = mommy.make(Questionnaire, type=Questionnaire.TOP)
+        mommy.make(Question, questionnaire=top_course_questionnaire, type="L")
+        cls.course.general_contribution.questionnaires.set([top_course_questionnaire])
         questionnaire = mommy.make(Questionnaire)
         question = mommy.make(Question, questionnaire=questionnaire, type='T')
         contribution = mommy.make(Contribution, course=cls.course, contributor=mommy.make(UserProfile), questionnaires=[questionnaire])
@@ -1283,7 +1289,7 @@ def test_comments_showing_up(self):
         self.get_assert_403(self.url, user='staff')
 
         # add additional voter
-        self.course.voters.add(self.student2)
+        self.let_user_vote_for_course(self.student2, self.course)
 
         # now it should work
         response = self.app.get(self.url, user='staff')
@@ -1590,7 +1596,10 @@ def setUpTestData(cls):
         mommy.make(UserProfile, username="staff.user", groups=[Group.objects.get(name="Staff")])
         cls.student1 = mommy.make(UserProfile)
         cls.student2 = mommy.make(UserProfile)
-        cls.course = mommy.make(Course, participants=[cls.student1, cls.student2], voters=[cls.student1])
+        cls.course = mommy.make(Course, participants=[cls.student1, cls.student2], voters=[cls.student1], state="in_evaluation")
+        top_course_questionnaire = mommy.make(Questionnaire, type=Questionnaire.TOP)
+        mommy.make(Question, questionnaire=top_course_questionnaire, type="L")
+        cls.course.general_contribution.questionnaires.set([top_course_questionnaire])
 
     def helper(self, old_state, expected_new_state, action, expect_errors=False):
         textanswer = mommy.make(TextAnswer, state=old_state)
@@ -1606,8 +1615,7 @@ def test_review_actions(self):
         # in a course with only one voter reviewing should fail
         self.helper(TextAnswer.NOT_REVIEWED, TextAnswer.PUBLISHED, "publish", expect_errors=True)
 
-        self.course.voters.add(self.student2)
-        self.course.save()
+        self.let_user_vote_for_course(self.student2, self.course)
 
         # now reviewing should work
         self.helper(TextAnswer.NOT_REVIEWED, TextAnswer.PUBLISHED, "publish")