Repository: e-valuation/EvaP

Issue:
Text answer visibility info
Next to each text question on the student's voting page a tooltip info icon should show which users (listed by their names) will be able to see the text result after the course was published. This includes the person to which the text answer belongs (+delegates), the responsible person (+delegates) and all other contributors who can see all text answers. A final line will state something like "and the staff users/evaluation team/student representatives" or similar (without listing individual names).


Discussion:
i have a bit of the fear that this list is by default rather long, might induce the feeling to voters that their comments are not confidential at all, and might keep them back from speaking freely.

just an idea, maybe a one-time email to responsibles/editors that we now display this information, they can use the preview to see this and this might be a good time to check whether the list of delegates is reasonably short, and they shouldn't be too liberal in giving viewing rights per course.

But maybe this isn't actually a problem in practice...
you're right. when writing the issue i felt it will not improve the situation. so before anyone implements this we need to discuss it in more detail.
for the record i still think this is a good thing since it adds transparency and users can make more informed decisions. however, if the result in practice is less open feedback, we might need other/additional solutions.
in the end this feature should help students to understand what's happening with their answers. they should know who will possibly work with the results and where the feedback will end up.
one option would be to just write the number of delegates instead of their names to make the list less intimidating. this might be a good idea anyway because we might not want to leak the list of delegates of all the contributors to all the participants.

with this, the average tooltip for a contributor (assuming they have no delegates) could be something like this:

"Besides the evaluated person themselves, this comment can be seen by Prof. Dr. John Smith, their 2 delegates, and the student representatives."

I think that's okay.
@janno42 Thanks for pointing me to this ticket!

This issue seems like an important and valuable improvement to the system and its transparency to me.
For example, I intuitively assumed that the current field "Kommentare zur Person / Personal Feedback" would be viewable only by the person in question and not by responsible persons (+delegates) .

Resolving patch:
diff --git a/evap/results/tools.py b/evap/results/tools.py
--- a/evap/results/tools.py
+++ b/evap/results/tools.py
@@ -5,8 +5,9 @@
 
 from django.conf import settings
 from django.core.cache import caches
+from django.db.models import Q
 
-from evap.evaluation.models import TextAnswer, RatingAnswerCounter, Questionnaire, Question
+from evap.evaluation.models import Contribution, Question, Questionnaire, RatingAnswerCounter, TextAnswer, UserProfile
 
 
 GRADE_COLORS = {
@@ -84,13 +85,15 @@ def is_published(self):
 
 
 class TextResult:
-    def __init__(self, question, answers):
+    def __init__(self, question, answers, answers_visible_to=None):
         assert question.is_text_question
         self.question = question
         self.answers = answers
+        self.answers_visible_to = answers_visible_to
 
 
 HeadingResult = namedtuple('HeadingResult', ('question'))
+TextAnswerVisibility = namedtuple('TextAnswerVisibility', ('visible_by_contribution', 'visible_by_delegation_count'))
 
 
 def get_counts(answer_counters):
@@ -137,7 +140,7 @@ def _collect_results_impl(course):
                     results.append(RatingResult(question, counts))
                 elif question.is_text_question and course.can_publish_text_results:
                     answers = TextAnswer.objects.filter(contribution=contribution, question=question, state__in=[TextAnswer.PRIVATE, TextAnswer.PUBLISHED])
-                    results.append(TextResult(question=question, answers=answers))
+                    results.append(TextResult(question=question, answers=answers, answers_visible_to=textanswers_visible_to(contribution)))
                 elif question.is_heading_question:
                     results.append(HeadingResult(question=question))
             questionnaire_results.append(QuestionnaireResult(questionnaire, results))
@@ -232,3 +235,19 @@ def get_grade_color(grade):
     next_lower = int(grade)
     next_higher = int(ceil(grade))
     return color_mix(GRADE_COLORS[next_lower], GRADE_COLORS[next_higher], grade - next_lower)
+
+
+def textanswers_visible_to(contribution):
+    if contribution.is_general:
+        contributors = UserProfile.objects.filter(
+            Q(contributions__course=contribution.course) & (
+                Q(contributions__comment_visibility=Contribution.ALL_COMMENTS) |
+                Q(contributions__comment_visibility=Contribution.GENERAL_COMMENTS)
+            )
+        ).distinct().order_by('contributions__comment_visibility')
+    else:
+        contributors = [contribution.contributor]
+        if not contribution.responsible:
+            contributors.extend(UserProfile.objects.filter(contributions__course=contribution.course, contributions__comment_visibility=Contribution.ALL_COMMENTS).exclude(id=contribution.contributor.id))
+    num_delegates = len(set(UserProfile.objects.filter(represented_users__in=contributors).distinct()) - set(contributors))
+    return TextAnswerVisibility(visible_by_contribution=contributors, visible_by_delegation_count=num_delegates)
diff --git a/evap/results/views.py b/evap/results/views.py
--- a/evap/results/views.py
+++ b/evap/results/views.py
@@ -230,15 +230,23 @@ def user_can_see_text_answer(user, represented_users, text_answer, view):
     if text_answer.is_private:
         return contributor == user
 
+    # NOTE: when changing this behavior, make sure all changes are also reflected in results.tools.textanswers_visible_to
+    # and in results.tests.test_tools.TestTextAnswerVisibilityInfo
     if text_answer.is_published:
+        # text answers about responsible contributors can only be seen by the users themselves and by their delegates
+        # they can not be seen by other responsible contributors
         if text_answer.contribution.responsible:
-            return contributor == user or user in contributor.delegates.all()
+            return contributor in represented_users
 
+        # users can see textanswers if the contributor is one of their represented users (which includes the user itself)
         if contributor in represented_users:
             return True
+        # users can see textanswers if one of their represented users has comment visiblity ALL_COMMENTS for the course
         if text_answer.contribution.course.contributions.filter(
                 contributor__in=represented_users, comment_visibility=Contribution.ALL_COMMENTS).exists():
             return True
+        # users can see textanswers from general contributions if one of their represented users has comment visibility
+        # GENERAL_COMMENTS for the course
         if text_answer.contribution.is_general and text_answer.contribution.course.contributions.filter(
                 contributor__in=represented_users, comment_visibility=Contribution.GENERAL_COMMENTS).exists():
             return True
diff --git a/evap/student/views.py b/evap/student/views.py
--- a/evap/student/views.py
+++ b/evap/student/views.py
@@ -15,7 +15,7 @@
 from evap.student.forms import QuestionnaireVotingForm
 from evap.student.tools import question_id
 
-from evap.results.tools import calculate_average_distribution, distribution_to_grade
+from evap.results.tools import calculate_average_distribution, distribution_to_grade, textanswers_visible_to
 
 SUCCESS_MAGIC_STRING = 'vote submitted successfully'
 
@@ -79,7 +79,7 @@ def get_valid_form_groups_or_render_vote_page(request, course, preview, for_rend
 
     course_form_group = form_groups.pop(course.general_contribution)
 
-    contributor_form_groups = [(contribution.contributor, contribution.label, form_group, any(form.errors for form in form_group)) for contribution, form_group in form_groups.items()]
+    contributor_form_groups = [(contribution.contributor, contribution.label, form_group, any(form.errors for form in form_group), textanswers_visible_to(contribution)) for contribution, form_group in form_groups.items()]
     course_form_group_top = [questions_form for questions_form in course_form_group if questions_form.questionnaire.is_above_contributors]
     course_form_group_bottom = [questions_form for questions_form in course_form_group if questions_form.questionnaire.is_below_contributors]
     if not contributor_form_groups:
@@ -100,7 +100,9 @@ def get_valid_form_groups_or_render_vote_page(request, course, preview, for_rend
         success_magic_string=SUCCESS_MAGIC_STRING,
         success_redirect_url=reverse('student:index'),
         evaluation_ends_soon=course.evaluation_ends_soon(),
-        for_rendering_in_modal=for_rendering_in_modal)
+        for_rendering_in_modal=for_rendering_in_modal,
+        general_contribution_textanswers_visible_to=textanswers_visible_to(course.general_contribution),
+    )
     return None, render(request, "student_vote.html", template_data)
 
 


Test patch:
diff --git a/evap/results/tests/test_tools.py b/evap/results/tests/test_tools.py
--- a/evap/results/tests/test_tools.py
+++ b/evap/results/tests/test_tools.py
@@ -6,8 +6,10 @@
 
 from model_mommy import mommy
 
-from evap.evaluation.models import Contribution, RatingAnswerCounter, Questionnaire, Question, Course, UserProfile
-from evap.results.tools import get_collect_results_cache_key, calculate_average_distribution, collect_results, distribution_to_grade, get_single_result_rating_result
+from evap.evaluation.models import Contribution, Course, Question, Questionnaire, RatingAnswerCounter, TextAnswer, UserProfile
+from evap.results.tools import calculate_average_distribution, collect_results, distribution_to_grade, \
+    get_collect_results_cache_key, get_single_result_rating_result, textanswers_visible_to
+from evap.results.views import user_can_see_text_answer
 from evap.staff.tools import merge_users
 
 
@@ -190,3 +192,70 @@ def test_result_calculation_with_no_contributor_rating_question_does_not_fail(se
 
         distribution = calculate_average_distribution(course)
         self.assertEqual(distribution[0], 1)
+
+
+class TestTextAnswerVisibilityInfo(TestCase):
+    @classmethod
+    def setUpTestData(cls):
+        cls.delegate1 = mommy.make(UserProfile, username="delegate1")
+        cls.delegate2 = mommy.make(UserProfile, username="delegate2")
+        cls.contributor_own = mommy.make(UserProfile, username="contributor_own", delegates=[cls.delegate1])
+        cls.contributor_course = mommy.make(UserProfile, username="contributor_course", delegates=[cls.delegate2])
+        cls.contributor_all = mommy.make(UserProfile, username="contributor_all")
+        cls.responsible1 = mommy.make(UserProfile, username="responsible1", delegates=[cls.delegate1, cls.contributor_course])
+        cls.responsible2 = mommy.make(UserProfile, username="responsible2")
+        cls.other_user = mommy.make(UserProfile, username="other_user")
+
+        cls.course = mommy.make(Course, state='published', can_publish_text_results=True)
+        cls.questionnaire = mommy.make(Questionnaire)
+        cls.question = mommy.make(Question, questionnaire=cls.questionnaire, type="T")
+        cls.general_contribution = cls.course.general_contribution
+        cls.general_contribution.questionnaires.set([cls.questionnaire])
+        cls.responsible1_contribution = mommy.make(Contribution, contributor=cls.responsible1, course=cls.course,
+            questionnaires=[cls.questionnaire], responsible=True, can_edit=True, comment_visibility=Contribution.ALL_COMMENTS)
+        cls.responsible2_contribution = mommy.make(Contribution, contributor=cls.responsible2, course=cls.course,
+            questionnaires=[cls.questionnaire], responsible=True, can_edit=True, comment_visibility=Contribution.ALL_COMMENTS)
+        cls.contributor_own_contribution = mommy.make(Contribution, contributor=cls.contributor_own, course=cls.course,
+            questionnaires=[cls.questionnaire], comment_visibility=Contribution.OWN_COMMENTS)
+        cls.contributor_course_contribution = mommy.make(Contribution, contributor=cls.contributor_course, course=cls.course,
+            questionnaires=[cls.questionnaire], comment_visibility=Contribution.GENERAL_COMMENTS)
+        cls.contributor_all_contribution = mommy.make(Contribution, contributor=cls.contributor_all, course=cls.course,
+            questionnaires=[cls.questionnaire], comment_visibility=Contribution.ALL_COMMENTS)
+        cls.general_contribution_textanswer = mommy.make(TextAnswer, question=cls.question, contribution=cls.general_contribution, state=TextAnswer.PUBLISHED)
+        cls.responsible1_textanswer = mommy.make(TextAnswer, question=cls.question, contribution=cls.responsible1_contribution, state=TextAnswer.PUBLISHED)
+        cls.responsible2_textanswer = mommy.make(TextAnswer, question=cls.question, contribution=cls.responsible2_contribution, state=TextAnswer.PUBLISHED)
+        cls.contributor_own_textanswer = mommy.make(TextAnswer, question=cls.question, contribution=cls.contributor_own_contribution, state=TextAnswer.PUBLISHED)
+        cls.contributor_course_textanswer = mommy.make(TextAnswer, question=cls.question, contribution=cls.contributor_course_contribution, state=TextAnswer.PUBLISHED)
+        cls.contributor_all_textanswer = mommy.make(TextAnswer, question=cls.question, contribution=cls.contributor_all_contribution, state=TextAnswer.PUBLISHED)
+
+    def test_correct_contributors_and_delegate_count_are_shown_in_textanswer_visibility_info(self):
+        textanswers = [
+            self.general_contribution_textanswer, self.responsible1_textanswer, self.responsible2_textanswer,
+            self.contributor_own_textanswer, self.contributor_course_textanswer, self.contributor_all_textanswer
+        ]
+        visible_to = [textanswers_visible_to(textanswer.contribution) for textanswer in textanswers]
+        users_seeing_contribution = [(set(), set()) for _ in range(len(textanswers))]
+
+        for user in UserProfile.objects.all():
+            represented_users = [user] + list(user.represented_users.all())
+            for i in range(len(textanswers)):
+                if user_can_see_text_answer(user, represented_users, textanswers[i], 'full'):
+                    if user_can_see_text_answer(user, [user], textanswers[i], 'full'):
+                        users_seeing_contribution[i][0].add(user)
+                    else:
+                        users_seeing_contribution[i][1].add(user)
+
+        for i in range(len(textanswers)):
+            self.assertCountEqual(visible_to[i][0], users_seeing_contribution[i][0])
+
+        expected_delegate_counts = [
+            2,  # delegate1, delegate2
+            2,  # delegate1, contributor_course
+            0,
+            2,  # delegate1, contributor_course
+            2,  # delegate1, delegate2
+            2,  # delegate1, contributor_course
+        ]
+
+        for i in range(len(textanswers)):
+            self.assertTrue(visible_to[i][1] == len(users_seeing_contribution[i][1]) == expected_delegate_counts[i])
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
@@ -497,6 +497,10 @@ def test_textanswer_visibility_for_student_external(self):
         # the external user does not participate in or contribute to the course and therefore can't see the results
         self.app.get("/results/semester/1/course/1", user='student_external', status=403)
 
+    def test_textanswer_visibility_info_is_shown(self):
+        page = self.app.get("/results/semester/1/course/1", user='contributor')
+        self.assertIn("can be seen by: contributor user", page)
+
 
 class TestResultsOtherContributorsListOnExportView(WebTest):
     @classmethod
diff --git a/evap/student/tests/test_views.py b/evap/student/tests/test_views.py
--- a/evap/student/tests/test_views.py
+++ b/evap/student/tests/test_views.py
@@ -180,11 +180,11 @@ def test_user_cannot_vote_multiple_times(self):
     def test_user_cannot_vote_for_themselves(self):
         response = self.app.get(self.url, user=self.contributor1, status=200)
 
-        for contributor, _, _, _ in response.context['contributor_form_groups']:
+        for contributor, __, __, __, __ in response.context['contributor_form_groups']:
             self.assertNotEqual(contributor, self.contributor1, "Contributor should not see the questionnaire about themselves")
 
         response = self.app.get(self.url, user=self.voting_user1, status=200)
-        self.assertTrue(any(contributor == self.contributor1 for contributor, _, _, _ in response.context['contributor_form_groups']),
+        self.assertTrue(any(contributor == self.contributor1 for contributor, __, __, __, __ in response.context['contributor_form_groups']),
             "Regular students should see the questionnaire about a contributor")
 
     def test_user_logged_out(self):
@@ -241,3 +241,7 @@ def test_user_checked_bottom_text_answer_publish_confirmation(self):
 
     def test_user_did_not_check_text_answer_publish_confirmation(self):
         self.helper_test_answer_publish_confirmation(None)
+
+    def test_textanswer_visibility_is_shown(self):
+        page = self.app.get(self.url, user=self.voting_user1.username, status=200)
+        self.assertIn("can be seen by: {}".format(self.contributor1.full_name), page)