Repository: e-valuation/EvaP

Issue:
Urgent review only for closed evaluations
Text answer review should be marked as "urgent" on the staff semester page only for evaluations where the evaluation period already ended (state `evaluated`).


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
@@ -1,5 +1,6 @@
 from collections import namedtuple, defaultdict
 from datetime import datetime, date, timedelta
+from enum import Enum, auto
 import logging
 import secrets
 import uuid
@@ -393,6 +394,13 @@ class Evaluation(models.Model):
     # whether to wait for grade uploading before publishing results
     wait_for_grade_upload_before_publishing = models.BooleanField(verbose_name=_("wait for grade upload before publishing"), default=True)
 
+    class TextAnswerReviewState(Enum):
+        do_not_call_in_templates = True
+        NO_TEXTANSWERS = auto()
+        REVIEW_NEEDED = auto()
+        REVIEW_URGENT = auto()
+        REVIEWED = auto()
+
     class Meta:
         unique_together = (
             ('course', 'name_de'),
@@ -700,6 +708,24 @@ def reviewed_textanswer_set(self):
     def num_reviewed_textanswers(self):
         return self.reviewed_textanswer_set.count()
 
+    @property
+    def textanswer_review_state(self):
+        if self.num_textanswers == 0:
+            return self.TextAnswerReviewState.NO_TEXTANSWERS
+
+        if self.num_textanswers == self.num_reviewed_textanswers:
+            return self.TextAnswerReviewState.REVIEWED
+
+        if self.state != "evaluated":
+            return self.TextAnswerReviewState.REVIEW_NEEDED
+
+        if (self.course.final_grade_documents
+                or self.course.gets_no_grade_documents
+                or not self.wait_for_grade_upload_before_publishing):
+            return self.TextAnswerReviewState.REVIEW_URGENT
+
+        return self.TextAnswerReviewState.REVIEW_NEEDED
+
     @property
     def ratinganswer_counters(self):
         return RatingAnswerCounter.objects.filter(contribution__evaluation=self)


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
@@ -12,6 +12,7 @@
 
 from evap.evaluation.models import (Contribution, Course, CourseType, EmailTemplate, Evaluation, NotArchiveable,
                                     Question, Questionnaire, RatingAnswerCounter, Semester, TextAnswer, UserProfile)
+from evap.grades.models import GradeDocument
 from evap.evaluation.tests.tools import let_user_vote_for_evaluation
 from evap.results.tools import calculate_average_distribution
 from evap.results.views import get_evaluation_result_template_fragment_cache_key
@@ -307,6 +308,81 @@ def test_publishing_and_unpublishing_effect_on_template_cache(self):
         self.assertIsNone(caches['results'].get(get_evaluation_result_template_fragment_cache_key(evaluation.id, "de", True)))
         self.assertIsNone(caches['results'].get(get_evaluation_result_template_fragment_cache_key(evaluation.id, "de", False)))
 
+    # pylint: disable=invalid-name
+    def assert_textanswer_review_state(
+            self,
+            evaluation,
+            expected_default_value,
+            expected_value_with_gets_no_grade_documents,
+            expected_value_with_wait_for_grade_upload_before_publishing,
+            expected_value_after_grade_upload):
+
+        self.assertEqual(evaluation.textanswer_review_state, expected_default_value)
+
+        evaluation.course.gets_no_grade_documents = True
+        self.assertEqual(evaluation.textanswer_review_state, expected_value_with_gets_no_grade_documents)
+        evaluation.course.gets_no_grade_documents = False
+
+        evaluation.wait_for_grade_upload_before_publishing = True
+        self.assertEqual(evaluation.textanswer_review_state, expected_value_with_wait_for_grade_upload_before_publishing)
+
+        grade_document = baker.make(GradeDocument, type=GradeDocument.Type.FINAL_GRADES, course=evaluation.course)
+        self.assertEqual(evaluation.textanswer_review_state, expected_value_after_grade_upload)
+        grade_document.delete()
+
+        evaluation.wait_for_grade_upload_before_publishing = False
+
+    def test_textanswer_review_state(self):
+        evaluation = baker.make(
+            Evaluation,
+            state="in_evaluation",
+            can_publish_text_results=True,
+            wait_for_grade_upload_before_publishing=False
+        )
+
+        self.assert_textanswer_review_state(
+            evaluation,
+            evaluation.TextAnswerReviewState.NO_TEXTANSWERS,
+            evaluation.TextAnswerReviewState.NO_TEXTANSWERS,
+            evaluation.TextAnswerReviewState.NO_TEXTANSWERS,
+            evaluation.TextAnswerReviewState.NO_TEXTANSWERS,
+        )
+
+        textanswer = baker.make(TextAnswer, contribution=evaluation.general_contribution)
+        del evaluation.num_textanswers  # reset cached_property cache
+
+        # text_answer_review_state should be REVIEW_NEEDED as long as we are still in_evaluation
+        self.assert_textanswer_review_state(
+            evaluation,
+            evaluation.TextAnswerReviewState.REVIEW_NEEDED,
+            evaluation.TextAnswerReviewState.REVIEW_NEEDED,
+            evaluation.TextAnswerReviewState.REVIEW_NEEDED,
+            evaluation.TextAnswerReviewState.REVIEW_NEEDED,
+        )
+
+        evaluation.evaluation_end()
+        evaluation.save()
+
+        self.assert_textanswer_review_state(
+            evaluation,
+            evaluation.TextAnswerReviewState.REVIEW_URGENT,
+            evaluation.TextAnswerReviewState.REVIEW_URGENT,  # course has `gets_no_grade_documents`
+            evaluation.TextAnswerReviewState.REVIEW_NEEDED,  # still waiting for grades
+            evaluation.TextAnswerReviewState.REVIEW_URGENT,  # grades were uploaded
+        )
+
+        textanswer.state = TextAnswer.State.PUBLISHED
+        textanswer.save()
+        del evaluation.num_reviewed_textanswers  # reset cached_property cache
+
+        self.assert_textanswer_review_state(
+            evaluation,
+            evaluation.TextAnswerReviewState.REVIEWED,
+            evaluation.TextAnswerReviewState.REVIEWED,
+            evaluation.TextAnswerReviewState.REVIEWED,
+            evaluation.TextAnswerReviewState.REVIEWED,
+        )
+
 
 class TestCourse(TestCase):
     def test_can_be_deleted_by_manager(self):
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
@@ -1,6 +1,6 @@
 import datetime
 import os
-from unittest.mock import patch
+from unittest.mock import patch, PropertyMock
 
 from django.conf import settings
 from django.contrib.auth.models import Group
@@ -421,7 +421,7 @@ class TestSemesterView(WebTest):
 
     @classmethod
     def setUpTestData(cls):
-        baker.make(UserProfile, email='manager@institution.example.com', groups=[Group.objects.get(name='Manager')])
+        cls.user = baker.make(UserProfile, email='manager@institution.example.com', groups=[Group.objects.get(name='Manager')])
         cls.semester = baker.make(Semester, pk=1)
         cls.evaluation1 = baker.make(Evaluation, name_de="Evaluation 1", name_en="Evaluation 1",
             course=baker.make(Course, name_de="A", name_en="B", semester=cls.semester))
@@ -465,6 +465,38 @@ def test_badge_for_external_responsibles(self):
         response = self.app.get(self.url, user="manager@institution.example.com")
         self.assertContains(response, 'External responsible')
 
+    @patch("evap.evaluation.models.Evaluation.textanswer_review_state", new_callable=PropertyMock)
+    def test_textanswer_review_state_tags(self, textanswer_review_state_mock):
+        """ Regression test for #1465 """
+
+        evaluation = baker.make(
+            Evaluation,
+            state="in_evaluation",
+            can_publish_text_results=True,
+            course__semester=self.semester,
+        )
+        baker.make(TextAnswer, contribution=evaluation.general_contribution)
+
+        textanswer_review_state_mock.return_value = Evaluation.TextAnswerReviewState.NO_TEXTANSWERS
+        page = self.app.get(f'/staff/semester/{evaluation.course.semester.id}', user=self.user)
+        expected_count = page.body.decode().count('no_textanswers')
+
+        textanswer_review_state_mock.return_value = Evaluation.TextAnswerReviewState.REVIEW_NEEDED
+        page = self.app.get(f'/staff/semester/{evaluation.course.semester.id}', user=self.user)
+        # + 1 because the buttons at the top of the page contain it two times (once for _urgent)
+        self.assertEqual(page.body.decode().count('unreviewed_textanswers'), expected_count + 1)
+        self.assertEqual(page.body.decode().count('no_textanswers'), 1)
+
+        textanswer_review_state_mock.return_value = Evaluation.TextAnswerReviewState.REVIEW_URGENT
+        page = self.app.get(f'/staff/semester/{evaluation.course.semester.id}', user=self.user)
+        self.assertEqual(page.body.decode().count('unreviewed_textanswers_urgent'), expected_count)
+        self.assertEqual(page.body.decode().count('no_textanswers'), 1)
+
+        textanswer_review_state_mock.return_value = Evaluation.TextAnswerReviewState.REVIEWED
+        page = self.app.get(f'/staff/semester/{evaluation.course.semester.id}', user=self.user)
+        self.assertEqual(page.body.decode().count('textanswers_reviewed'), expected_count)
+        self.assertEqual(page.body.decode().count('no_textanswers'), 1)
+
 
 class TestGetEvaluationsWithPrefetchedData(TestCase):
     @staticmethod