Repository: e-valuation/EvaP

Issue:
Personal result export
On each user profile page the list "Courses contributing to (active semester)" should be extended to hold all courses the user is contributing to, grouped by semester (current semester on top). Each entry has an "Personal export" button that links to this course's results page where
- individual cards for all other contributors are not shown (only collapsing is not sufficient because in some situations we have more than 20 contributors and the card headers would still use up too much space) and
- only text answers which are visible for this user are shown.

This helps to export results for a user as PDF on request. The buttons linking to the personal export view could also be added on the contributor index page.


Discussion:
I would expect this to be easily accessible by the contributors themselves.

edit: disregard most of the following, I had "output everything on one page" in mind, which wasn't the intention of @janno42 

Of course we can give staff users access to this, but is that really necessary? I'm not directly opposed to that, but would like to avoid to make an "output all information about a user"-feature accessible for more people than necessary. For external users, whose accounts are disabled rather quickly, this might be necessary, but for internal users I think it's perfectly reasonable for staff users to tell them "in your menu, click that button" or "click this link here and login".

Of course this would require this page to look ok when printing/exporting to pdf out of the box without additional work, but that would be nice in any case.
The results pages should already be optimized for printing. I would expect this feature to just provide links to all relevant results pages which are displayed a little bit filtered as described.
The same filtered result view could also be provided to the contributors as a "personal printing view".
Other contributors should be shown (e.g. in a comma-separated list)

Resolving patch:
diff --git a/evap/evaluation/models.py b/evap/evaluation/models.py
--- a/evap/evaluation/models.py
+++ b/evap/evaluation/models.py
@@ -519,6 +519,9 @@ def is_user_contributor_or_delegate(self, user):
             return False
         return self.contributions.filter(Q(contributor=user) | Q(contributor__in=user.represented_users.all())).exists()
 
+    def is_user_contributor(self, user):
+        return self.contributions.filter(contributor=user).exists()
+
     @property
     def textanswer_set(self):
         return TextAnswer.objects.filter(contribution__course=self)
diff --git a/evap/results/views.py b/evap/results/views.py
--- a/evap/results/views.py
+++ b/evap/results/views.py
@@ -11,7 +11,7 @@
 from django.contrib.auth.decorators import login_required
 from django.utils import translation
 
-from evap.evaluation.models import Semester, Degree, Contribution, Course, CourseType
+from evap.evaluation.models import Semester, Degree, Contribution, Course, CourseType, UserProfile
 from evap.evaluation.auth import internal_required
 from evap.results.tools import collect_results, calculate_average_distribution, distribution_to_grade, \
     TextAnswer, TextResult, HeadingResult, get_single_result_rating_result
@@ -108,21 +108,26 @@ def course_detail(request, semester_id, course_id):
     course_result = collect_results(course)
 
     if request.user.is_reviewer:
-        public_view = request.GET.get('public_view') != 'false'  # if parameter is not given, show public view.
+        view = request.GET.get('view', 'public')  # if parameter is not given, show public view.
     else:
-        public_view = request.GET.get('public_view') == 'true'  # if parameter is not given, show own view.
+        view = request.GET.get('view', 'full')  # if parameter is not given, show own view.
 
-    # redirect to non-public view if there is none because the results have not been published
-    if not course.can_publish_rating_results:
-        public_view = False
+    view_as_user = request.user
+    if view == 'export' and request.user.is_staff:
+        view_as_user = UserProfile.objects.get(id=int(request.GET.get('contributor_id', request.user.id)))
 
-    represented_users = list(request.user.represented_users.all()) + [request.user]
+    represented_users = [view_as_user]
+    if view != 'export':
+        represented_users += list(view_as_user.represented_users.all())
+    # redirect to non-public view if there is none because the results have not been published
+    if not course.can_publish_rating_results and view == 'public':
+        view = 'full'
 
     # remove text answers if the user may not see them
     for questionnaire_result in course_result.questionnaire_results:
         for question_result in questionnaire_result.question_results:
             if isinstance(question_result, TextResult):
-                question_result.answers = [answer for answer in question_result.answers if user_can_see_text_answer(request.user, represented_users, answer, public_view)]
+                question_result.answers = [answer for answer in question_result.answers if user_can_see_text_answer(view_as_user, represented_users, answer, view)]
         # remove empty TextResults
         questionnaire_result.question_results = [result for result in questionnaire_result.question_results if not isinstance(result, TextResult) or len(result.answers) > 0]
 
@@ -155,7 +160,7 @@ def course_detail(request, semester_id, course_id):
                     course_questionnaire_results_bottom.append(questionnaire_result)
                 else:
                     course_questionnaire_results_top.append(questionnaire_result)
-        else:
+        elif view != 'export' or view_as_user.id == contribution_result.contributor.id:
             contributor_contribution_results.append(contribution_result)
 
     if not contributor_contribution_results:
@@ -165,15 +170,23 @@ def course_detail(request, semester_id, course_id):
     course.distribution = calculate_average_distribution(course)
     course.avg_grade = distribution_to_grade(course.distribution)
 
+    other_contributors = []
+    if view == 'export':
+        other_contributors = [contribution_result.contributor for contribution_result in course_result.contribution_results if contribution_result.contributor not in [None, view_as_user]]
+
     template_data = dict(
-            course=course,
-            course_questionnaire_results_top=course_questionnaire_results_top,
-            course_questionnaire_results_bottom=course_questionnaire_results_bottom,
-            contributor_contribution_results=contributor_contribution_results,
-            reviewer=request.user.is_reviewer,
-            contributor=course.is_user_contributor_or_delegate(request.user),
-            can_download_grades=request.user.can_download_grades,
-            public_view=public_view)
+        course=course,
+        course_questionnaire_results_top=course_questionnaire_results_top,
+        course_questionnaire_results_bottom=course_questionnaire_results_bottom,
+        contributor_contribution_results=contributor_contribution_results,
+        is_reviewer=view_as_user.is_reviewer,
+        is_contributor=course.is_user_contributor(view_as_user),
+        is_contributor_or_delegate=course.is_user_contributor_or_delegate(view_as_user),
+        can_download_grades=view_as_user.can_download_grades,
+        view=view,
+        view_as_user=view_as_user,
+        other_contributors=other_contributors,
+    )
     return render(request, "results_course_detail.html", template_data)
 
 
@@ -200,16 +213,20 @@ def add_warnings(course, course_result):
             rating_result.warning = questionnaire_result.warning or rating_result.has_answers and rating_result.count_sum < questionnaire_warning_thresholds[questionnaire_result.questionnaire]
 
 
-def user_can_see_text_answer(user, represented_users, text_answer, public_view=False):
+def user_can_see_text_answer(user, represented_users, text_answer, view):
     assert text_answer.state in [TextAnswer.PRIVATE, TextAnswer.PUBLISHED]
+    contributor = text_answer.contribution.contributor
 
-    if public_view:
+    if view == 'public':
         return False
-    if user.is_reviewer:
+    elif view == 'export':
+        if text_answer.is_private:
+            return False
+        if not text_answer.contribution.is_general and contributor != user:
+            return False
+    elif user.is_reviewer:
         return True
 
-    contributor = text_answer.contribution.contributor
-
     if text_answer.is_private:
         return contributor == user
 
diff --git a/evap/staff/views.py b/evap/staff/views.py
--- a/evap/staff/views.py
+++ b/evap/staff/views.py
@@ -1262,7 +1262,8 @@ def notify_reward_points(grantings, **_kwargs):
     user = get_object_or_404(UserProfile, id=user_id)
     form = UserForm(request.POST or None, request.FILES or None, instance=user)
 
-    courses_contributing_to = Course.objects.filter(semester=Semester.active_semester(), contributions__contributor=user)
+    semesters_with_courses = Semester.objects.filter(course__contributions__contributor=user).distinct()
+    courses_contributing_to = [(semester, Course.objects.filter(semester=semester, contributions__contributor=user)) for semester in semesters_with_courses]
 
     if form.is_valid():
         form.save()


Test patch:
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
@@ -2,7 +2,8 @@
 from django.test.testcases import TestCase
 from model_mommy import mommy
 
-from evap.evaluation.models import Semester, UserProfile, Course, Contribution, Questionnaire, Degree, Question, RatingAnswerCounter
+from evap.evaluation.models import Contribution, Course, Degree, Question, Questionnaire, RatingAnswerCounter, \
+    Semester, UserProfile
 from evap.evaluation.tests.tools import ViewTest, WebTest
 from evap.results.views import get_courses_with_prefetched_data
 
@@ -140,7 +141,7 @@ def test_default_view_is_public(self):
         url = '/results/semester/%s/course/%s' % (self.semester.id, self.course.id)
         random.seed(42)  # use explicit seed to always choose the same "random" slogan
         page_without_get_parameter = self.app.get(url, user='manager')
-        url = '/results/semester/%s/course/%s?public_view=true' % (self.semester.id, self.course.id)
+        url = '/results/semester/%s/course/%s?view=public' % (self.semester.id, self.course.id)
         random.seed(42)
         page_with_get_parameter = self.app.get(url, user='manager')
         url = '/results/semester/%s/course/%s?public_view=asdf' % (self.semester.id, self.course.id)
@@ -277,7 +278,7 @@ def test_textanswer_visibility_for_manager_before_publish(self):
         course.unpublish()
         course.save()
 
-        page = self.app.get("/results/semester/1/course/1?public_view=false", user='manager')
+        page = self.app.get("/results/semester/1/course/1?view=full", user='manager')
         self.assertIn(".course_orig_published.", page)
         self.assertNotIn(".course_orig_hidden.", page)
         self.assertNotIn(".course_orig_published_changed.", page)
@@ -298,7 +299,7 @@ def test_textanswer_visibility_for_manager_before_publish(self):
         self.assertNotIn(".other_responsible_orig_notreviewed.", page)
 
     def test_textanswer_visibility_for_manager(self):
-        page = self.app.get("/results/semester/1/course/1?public_view=false", user='manager')
+        page = self.app.get("/results/semester/1/course/1?view=full", user='manager')
         self.assertIn(".course_orig_published.", page)
         self.assertNotIn(".course_orig_hidden.", page)
         self.assertNotIn(".course_orig_published_changed.", page)
@@ -495,6 +496,219 @@ def test_textanswer_visibility_for_student_external(self):
         self.app.get("/results/semester/1/course/1", user='student_external', status=403)
 
 
+class TestResultsOtherContributorsListOnExportView(WebTest):
+    @classmethod
+    def setUpTestData(cls):
+        cls.semester = mommy.make(Semester, id=2)
+        cls.course = mommy.make(Course, id=21, state='published', semester=cls.semester)
+
+        questionnaire = mommy.make(Questionnaire)
+        mommy.make(Question, questionnaire=questionnaire, type="L")
+        cls.course.general_contribution.questionnaires.set([questionnaire])
+
+        responsible = mommy.make(UserProfile, username='responsible')
+        mommy.make(Contribution, course=cls.course, contributor=responsible, questionnaires=[questionnaire], can_edit=True, responsible=True, comment_visibility=Contribution.ALL_COMMENTS)
+        cls.other_contributor_1 = mommy.make(UserProfile, username='other contributor 1')
+        mommy.make(Contribution, course=cls.course, contributor=cls.other_contributor_1, questionnaires=[questionnaire], comment_visibility=Contribution.OWN_COMMENTS)
+        cls.other_contributor_2 = mommy.make(UserProfile, username='other contributor 2')
+        mommy.make(Contribution, course=cls.course, contributor=cls.other_contributor_2, questionnaires=[questionnaire], comment_visibility=Contribution.OWN_COMMENTS)
+
+    def test_contributor_list(self):
+        url = '/results/semester/{}/course/{}?view=export'.format(self.semester.id, self.course.id)
+        page = self.app.get(url, user='responsible')
+        self.assertIn("<li>{}</li>".format(self.other_contributor_1.username), page)
+        self.assertIn("<li>{}</li>".format(self.other_contributor_2.username), page)
+
+
+class TestResultsTextanswerVisibilityForExportView(WebTest):
+    fixtures = ['minimal_test_data_results']
+
+    @classmethod
+    def setUpTestData(cls):
+        manager_group = Group.objects.get(name="Manager")
+        cls.manager = mommy.make(UserProfile, username="manager", groups=[manager_group])
+
+    def test_textanswer_visibility_for_responsible(self):
+        page = self.app.get("/results/semester/1/course/1?view=export", user='responsible')
+
+        self.assertIn(".course_orig_published.", page)
+        self.assertNotIn(".course_orig_hidden.", page)
+        self.assertNotIn(".course_orig_published_changed.", page)
+        self.assertIn(".course_changed_published.", page)
+        self.assertIn(".responsible_orig_published.", page)
+        self.assertNotIn(".responsible_orig_hidden.", page)
+        self.assertNotIn(".responsible_orig_published_changed.", page)
+        self.assertIn(".responsible_changed_published.", page)
+        self.assertNotIn(".responsible_orig_private.", page)
+        self.assertNotIn(".responsible_orig_notreviewed.", page)
+        self.assertNotIn(".contributor_orig_published.", page)
+        self.assertNotIn(".contributor_orig_private.", page)
+        self.assertNotIn(".other_responsible_orig_published.", page)
+        self.assertNotIn(".other_responsible_orig_hidden.", page)
+        self.assertNotIn(".other_responsible_orig_published_changed.", page)
+        self.assertNotIn(".other_responsible_changed_published.", page)
+        self.assertNotIn(".other_responsible_orig_private.", page)
+        self.assertNotIn(".other_responsible_orig_notreviewed.", page)
+
+    def test_textanswer_visibility_for_other_responsible(self):
+        page = self.app.get("/results/semester/1/course/1?view=export", user='other_responsible')
+
+        self.assertIn(".course_orig_published.", page)
+        self.assertNotIn(".course_orig_hidden.", page)
+        self.assertNotIn(".course_orig_published_changed.", page)
+        self.assertIn(".course_changed_published.", page)
+        self.assertNotIn(".responsible_orig_published.", page)
+        self.assertNotIn(".responsible_orig_hidden.", page)
+        self.assertNotIn(".responsible_orig_published_changed.", page)
+        self.assertNotIn(".responsible_changed_published.", page)
+        self.assertNotIn(".responsible_orig_private.", page)
+        self.assertNotIn(".responsible_orig_notreviewed.", page)
+        self.assertNotIn(".contributor_orig_published.", page)
+        self.assertNotIn(".contributor_orig_private.", page)
+        self.assertIn(".other_responsible_orig_published.", page)
+        self.assertNotIn(".other_responsible_orig_hidden.", page)
+        self.assertNotIn(".other_responsible_orig_published_changed.", page)
+        self.assertIn(".other_responsible_changed_published.", page)
+        self.assertNotIn(".other_responsible_orig_private.", page)
+        self.assertNotIn(".other_responsible_orig_notreviewed.", page)
+
+    def test_textanswer_visibility_for_contributor(self):
+        page = self.app.get("/results/semester/1/course/1?view=export", user='contributor')
+
+        self.assertNotIn(".course_orig_published.", page)
+        self.assertNotIn(".course_orig_hidden.", page)
+        self.assertNotIn(".course_orig_published_changed.", page)
+        self.assertNotIn(".course_changed_published.", page)
+        self.assertNotIn(".responsible_orig_published.", page)
+        self.assertNotIn(".responsible_orig_hidden.", page)
+        self.assertNotIn(".responsible_orig_published_changed.", page)
+        self.assertNotIn(".responsible_changed_published.", page)
+        self.assertNotIn(".responsible_orig_private.", page)
+        self.assertNotIn(".responsible_orig_notreviewed.", page)
+        self.assertIn(".contributor_orig_published.", page)
+        self.assertNotIn(".contributor_orig_private.", page)
+        self.assertNotIn(".other_responsible_orig_published.", page)
+        self.assertNotIn(".other_responsible_orig_hidden.", page)
+        self.assertNotIn(".other_responsible_orig_published_changed.", page)
+        self.assertNotIn(".other_responsible_changed_published.", page)
+        self.assertNotIn(".other_responsible_orig_private.", page)
+        self.assertNotIn(".other_responsible_orig_notreviewed.", page)
+
+    def test_textanswer_visibility_for_contributor_course_comments(self):
+        page = self.app.get("/results/semester/1/course/1?view=export", user='contributor_course_comments')
+
+        self.assertIn(".course_orig_published.", page)
+        self.assertNotIn(".course_orig_hidden.", page)
+        self.assertNotIn(".course_orig_published_changed.", page)
+        self.assertIn(".course_changed_published.", page)
+        self.assertNotIn(".responsible_orig_published.", page)
+        self.assertNotIn(".responsible_orig_hidden.", page)
+        self.assertNotIn(".responsible_orig_published_changed.", page)
+        self.assertNotIn(".responsible_changed_published.", page)
+        self.assertNotIn(".responsible_orig_private.", page)
+        self.assertNotIn(".responsible_orig_notreviewed.", page)
+        self.assertNotIn(".contributor_orig_published.", page)
+        self.assertNotIn(".contributor_orig_private.", page)
+        self.assertNotIn(".other_responsible_orig_published.", page)
+        self.assertNotIn(".other_responsible_orig_hidden.", page)
+        self.assertNotIn(".other_responsible_orig_published_changed.", page)
+        self.assertNotIn(".other_responsible_changed_published.", page)
+        self.assertNotIn(".other_responsible_orig_private.", page)
+        self.assertNotIn(".other_responsible_orig_notreviewed.", page)
+
+    def test_textanswer_visibility_for_contributor_all_comments(self):
+        page = self.app.get("/results/semester/1/course/1?view=export", user='contributor_all_comments')
+
+        self.assertIn(".course_orig_published.", page)
+        self.assertNotIn(".course_orig_hidden.", page)
+        self.assertNotIn(".course_orig_published_changed.", page)
+        self.assertIn(".course_changed_published.", page)
+        self.assertNotIn(".responsible_orig_published.", page)
+        self.assertNotIn(".responsible_orig_hidden.", page)
+        self.assertNotIn(".responsible_orig_published_changed.", page)
+        self.assertNotIn(".responsible_changed_published.", page)
+        self.assertNotIn(".responsible_orig_private.", page)
+        self.assertNotIn(".responsible_orig_notreviewed.", page)
+        self.assertNotIn(".contributor_orig_published.", page)
+        self.assertNotIn(".contributor_orig_private.", page)
+        self.assertNotIn(".other_responsible_orig_published.", page)
+        self.assertNotIn(".other_responsible_orig_hidden.", page)
+        self.assertNotIn(".other_responsible_orig_published_changed.", page)
+        self.assertNotIn(".other_responsible_changed_published.", page)
+        self.assertNotIn(".other_responsible_orig_private.", page)
+        self.assertNotIn(".other_responsible_orig_notreviewed.", page)
+
+    def test_textanswer_visibility_for_student(self):
+        page = self.app.get("/results/semester/1/course/1?view=export", user='student')
+
+        self.assertNotIn(".course_orig_published.", page)
+        self.assertNotIn(".course_orig_hidden.", page)
+        self.assertNotIn(".course_orig_published_changed.", page)
+        self.assertNotIn(".course_changed_published.", page)
+        self.assertNotIn(".responsible_orig_published.", page)
+        self.assertNotIn(".responsible_orig_hidden.", page)
+        self.assertNotIn(".responsible_orig_published_changed.", page)
+        self.assertNotIn(".responsible_changed_published.", page)
+        self.assertNotIn(".responsible_orig_private.", page)
+        self.assertNotIn(".responsible_orig_notreviewed.", page)
+        self.assertNotIn(".contributor_orig_published.", page)
+        self.assertNotIn(".contributor_orig_private.", page)
+        self.assertNotIn(".other_responsible_orig_published.", page)
+        self.assertNotIn(".other_responsible_orig_hidden.", page)
+        self.assertNotIn(".other_responsible_orig_published_changed.", page)
+        self.assertNotIn(".other_responsible_changed_published.", page)
+        self.assertNotIn(".other_responsible_orig_private.", page)
+        self.assertNotIn(".other_responsible_orig_notreviewed.", page)
+
+    def test_textanswer_visibility_for_manager(self):
+        contributor_id = UserProfile.objects.get(username="responsible").id
+        page = self.app.get("/results/semester/1/course/1?view=export&contributor_id={}".format(contributor_id), user='manager')
+
+        self.assertIn(".course_orig_published.", page)
+        self.assertNotIn(".course_orig_hidden.", page)
+        self.assertNotIn(".course_orig_published_changed.", page)
+        self.assertIn(".course_changed_published.", page)
+        self.assertIn(".responsible_orig_published.", page)
+        self.assertNotIn(".responsible_orig_hidden.", page)
+        self.assertNotIn(".responsible_orig_published_changed.", page)
+        self.assertIn(".responsible_changed_published.", page)
+        self.assertNotIn(".responsible_orig_private.", page)
+        self.assertNotIn(".responsible_orig_notreviewed.", page)
+        self.assertNotIn(".contributor_orig_published.", page)
+        self.assertNotIn(".contributor_orig_private.", page)
+        self.assertNotIn(".other_responsible_orig_published.", page)
+        self.assertNotIn(".other_responsible_orig_hidden.", page)
+        self.assertNotIn(".other_responsible_orig_published_changed.", page)
+        self.assertNotIn(".other_responsible_changed_published.", page)
+        self.assertNotIn(".other_responsible_orig_private.", page)
+        self.assertNotIn(".other_responsible_orig_notreviewed.", page)
+
+    def test_textanswer_visibility_for_manager_contributor(self):
+        manager_group = Group.objects.get(name="Manager")
+        contributor = UserProfile.objects.get(username="contributor")
+        contributor.groups.add(manager_group)
+        page = self.app.get("/results/semester/1/course/1?view=export&contributor_id={}".format(contributor.id), user='contributor')
+
+        self.assertNotIn(".course_orig_published.", page)
+        self.assertNotIn(".course_orig_hidden.", page)
+        self.assertNotIn(".course_orig_published_changed.", page)
+        self.assertNotIn(".course_changed_published.", page)
+        self.assertNotIn(".responsible_orig_published.", page)
+        self.assertNotIn(".responsible_orig_hidden.", page)
+        self.assertNotIn(".responsible_orig_published_changed.", page)
+        self.assertNotIn(".responsible_changed_published.", page)
+        self.assertNotIn(".responsible_orig_private.", page)
+        self.assertNotIn(".responsible_orig_notreviewed.", page)
+        self.assertIn(".contributor_orig_published.", page)
+        self.assertNotIn(".contributor_orig_private.", page)
+        self.assertNotIn(".other_responsible_orig_published.", page)
+        self.assertNotIn(".other_responsible_orig_hidden.", page)
+        self.assertNotIn(".other_responsible_orig_published_changed.", page)
+        self.assertNotIn(".other_responsible_changed_published.", page)
+        self.assertNotIn(".other_responsible_orig_private.", page)
+        self.assertNotIn(".other_responsible_orig_notreviewed.", page)
+
+
 class TestArchivedResults(WebTest):
     @classmethod
     def setUpTestData(cls):