Repository: e-valuation/EvaP

Issue:
Restrict result access for external users
External users should only be able to see results for courses they participated in or contributed to. This could include completely removing the results pages for those users because own courses can be accessed directly.

Internal users should still be able to see the results for all (non-private) courses.


Discussion:

Resolving patch:
diff --git a/evap/evaluation/auth.py b/evap/evaluation/auth.py
--- a/evap/evaluation/auth.py
+++ b/evap/evaluation/auth.py
@@ -118,6 +118,15 @@ def _wrapped_view(request, *args, **kwargs):
     return decorator
 
 
+def internal_required(view_func):
+    """
+    Decorator for views that checks that the user is logged in and not an external user
+    """
+    def check_user(user):
+        return not user.is_external
+    return user_passes_test(check_user)(view_func)
+
+
 def staff_required(view_func):
     """
     Decorator for views that checks that the user is logged in and a staff member
diff --git a/evap/evaluation/models.py b/evap/evaluation/models.py
--- a/evap/evaluation/models.py
+++ b/evap/evaluation/models.py
@@ -299,7 +299,11 @@ def can_user_see_course(self, user):
             return True
         if self.is_user_contributor_or_delegate(user):
             return True
-        if self.is_private and user not in self.participants.all():
+        if user in self.participants.all():
+            return True
+        if self.is_private:
+            return False
+        if user.is_external:
             return False
         return True
 
diff --git a/evap/results/views.py b/evap/results/views.py
--- a/evap/results/views.py
+++ b/evap/results/views.py
@@ -5,17 +5,18 @@
 from django.contrib.auth.decorators import login_required
 
 from evap.evaluation.models import Semester, Degree, Contribution
+from evap.evaluation.auth import internal_required
 from evap.results.tools import calculate_results, calculate_average_grades_and_deviation, TextResult, RatingResult, COMMENT_STATES_REQUIRED_FOR_VISIBILITY
 
 
-@login_required
+@internal_required
 def index(request):
     semesters = Semester.get_all_with_published_courses()
 
     return render(request, "results_index.html", dict(semesters=semesters))
 
 
-@login_required
+@internal_required
 def semester_detail(request, semester_id):
     semester = get_object_or_404(Semester, id=semester_id)
     if request.user.is_reviewer:


Test patch:
diff --git a/evap/evaluation/tests/test_auth.py b/evap/evaluation/tests/test_auth.py
--- a/evap/evaluation/tests/test_auth.py
+++ b/evap/evaluation/tests/test_auth.py
@@ -3,7 +3,7 @@
 
 from model_mommy import mommy
 
-from evap.evaluation.models import UserProfile
+from evap.evaluation.models import Contribution, Course, UserProfile
 from evap.evaluation.tests.tools import WebTest
 
 
@@ -16,27 +16,30 @@ def setUpTestData(cls):
         cls.external_user.ensure_valid_login_key()
         cls.inactive_external_user = mommy.make(UserProfile, email="inactive@extern.com", is_active=False)
         cls.inactive_external_user.ensure_valid_login_key()
+        course = mommy.make(Course, state='published')
+        mommy.make(Contribution, course=course, contributor=cls.external_user, can_edit=True, responsible=True, comment_visibility=Contribution.ALL_COMMENTS)
+        mommy.make(Contribution, course=course, contributor=cls.inactive_external_user, can_edit=True, responsible=False, comment_visibility=Contribution.ALL_COMMENTS)
 
     def test_login_url_works(self):
-        self.assertRedirects(self.app.get(reverse("results:index")), "/?next=/results/")
+        self.assertRedirects(self.app.get(reverse("contributor:index")), "/?next=/contributor/")
 
-        url_with_key = reverse("results:index") + "?loginkey=%s" % self.external_user.login_key
+        url_with_key = reverse("contributor:index") + "?loginkey=%s" % self.external_user.login_key
         self.app.get(url_with_key)
 
     def test_login_key_valid_only_once(self):
-        page = self.app.get(reverse("results:index") + "?loginkey=%s" % self.external_user.login_key)
+        page = self.app.get(reverse("contributor:index") + "?loginkey=%s" % self.external_user.login_key)
         self.assertContains(page, 'Logged in as ' + self.external_user.full_name)
         page = self.app.get(reverse("django-auth-logout")).follow()
         self.assertContains(page, 'Not logged in')
-        page = self.app.get(reverse("results:index") + "?loginkey=%s" % self.external_user.login_key).follow()
+        page = self.app.get(reverse("contributor:index") + "?loginkey=%s" % self.external_user.login_key).follow()
         self.assertContains(page, 'The login URL is not valid anymore.')
         self.assertEqual(len(mail.outbox), 1)  # a new login key was sent
         self.external_user.refresh_from_db()
-        page = self.app.get(reverse("results:index") + "?loginkey=%s" % self.external_user.login_key)
+        page = self.app.get(reverse("contributor:index") + "?loginkey=%s" % self.external_user.login_key)
         self.assertContains(page, 'Logged in as ' + self.external_user.full_name)
 
     def test_inactive_external_users_can_not_login(self):
-        page = self.app.get(reverse("results:index") + "?loginkey=%s" % self.inactive_external_user.login_key).follow()
+        page = self.app.get(reverse("contributor:index") + "?loginkey=%s" % self.inactive_external_user.login_key).follow()
         self.assertContains(page, "Inactive users are not allowed to login")
         self.assertNotContains(page, "Logged in")
 
diff --git a/evap/evaluation/tests/test_tools.py b/evap/evaluation/tests/test_tools.py
--- a/evap/evaluation/tests/test_tools.py
+++ b/evap/evaluation/tests/test_tools.py
@@ -19,7 +19,7 @@ def test_signal_sets_language_if_none(self):
         """
         translation.activate('de')
 
-        user = mommy.make(UserProfile, language=None)
+        user = mommy.make(UserProfile, language=None, email="user@institution.example.com")
         user.ensure_valid_login_key()
 
         set_or_get_language(None, user, None)
@@ -32,7 +32,7 @@ def test_signal_doesnt_set_language(self):
         Activate 'en' as langauge and check, that user does not get this langauge as he has one.
         """
         translation.activate('en')
-        user = mommy.make(UserProfile, language='de')
+        user = mommy.make(UserProfile, language='de', email="user@institution.example.com")
         user.ensure_valid_login_key()
 
         self.app.get(reverse("results:index") + "?loginkey=%s" % user.login_key)
diff --git a/evap/results/fixtures/minimal_test_data_results.json b/evap/results/fixtures/minimal_test_data_results.json
--- a/evap/results/fixtures/minimal_test_data_results.json
+++ b/evap/results/fixtures/minimal_test_data_results.json
@@ -278,7 +278,7 @@
     "last_name": "user",
     "last_login": "2014-09-16T22:51:39.584",
     "password": "pbkdf2_sha256$12000$oyxddVrECuLk$ayPgZNkTANvBcWbTvPGaR4NBlqPELpElG7C4M1UN210=",
-    "email": "responsible@example.com",
+    "email": "responsible@institution.example.com",
     "title": "",
     "cc_users": [],
     "delegates": [3],
@@ -295,7 +295,7 @@
     "last_name": "user",
     "last_login": "2014-09-16T22:52:04.981",
     "password": "pbkdf2_sha256$12000$oyxddVrECuLk$ayPgZNkTANvBcWbTvPGaR4NBlqPELpElG7C4M1UN210=",
-    "email": "delegate@example.com",
+    "email": "delegate@institution.example.com",
     "title": "",
     "cc_users": [],
     "delegates": [],
@@ -312,7 +312,7 @@
     "last_name": "user",
     "last_login": "2014-09-16T22:52:27.021",
     "password": "pbkdf2_sha256$12000$oyxddVrECuLk$ayPgZNkTANvBcWbTvPGaR4NBlqPELpElG7C4M1UN210=",
-    "email": "cc@example.com",
+    "email": "cc@institution.example.com",
     "title": "",
     "cc_users": [],
     "delegates": [],
@@ -329,7 +329,7 @@
     "last_name": "user",
     "last_login": "2014-09-16T22:52:46.611",
     "password": "pbkdf2_sha256$12000$oyxddVrECuLk$ayPgZNkTANvBcWbTvPGaR4NBlqPELpElG7C4M1UN210=",
-    "email": "student@example.com",
+    "email": "student@institution.example.com",
     "title": "",
     "cc_users": [],
     "delegates": [],
@@ -346,7 +346,7 @@
     "last_name": "user",
     "last_login": "2014-09-16T22:53:02.506",
     "password": "pbkdf2_sha256$12000$oyxddVrECuLk$ayPgZNkTANvBcWbTvPGaR4NBlqPELpElG7C4M1UN210=",
-    "email": "contributor@example.com",
+    "email": "contributor@institution.example.com",
     "title": "",
     "cc_users": [],
     "delegates": [4],
@@ -363,7 +363,7 @@
     "last_name": "user",
     "last_login": "2014-09-16T22:53:02.506",
     "password": "pbkdf2_sha256$12000$oyxddVrECuLk$ayPgZNkTANvBcWbTvPGaR4NBlqPELpElG7C4M1UN210=",
-    "email": "contributor_course_comments@example.com",
+    "email": "contributor_course_comments@institution.example.com",
     "title": "",
     "cc_users": [],
     "delegates": [],
@@ -380,7 +380,7 @@
     "last_name": "user",
     "last_login": "2014-09-16T22:53:02.506",
     "password": "pbkdf2_sha256$12000$oyxddVrECuLk$ayPgZNkTANvBcWbTvPGaR4NBlqPELpElG7C4M1UN210=",
-    "email": "contributor_all_comments@example.com",
+    "email": "contributor_all_comments@institution.example.com",
     "title": "",
     "cc_users": [],
     "delegates": [],
@@ -397,7 +397,24 @@
     "last_name": "user",
     "last_login": "2014-09-16T22:51:39.584",
     "password": "pbkdf2_sha256$12000$oyxddVrECuLk$ayPgZNkTANvBcWbTvPGaR4NBlqPELpElG7C4M1UN210=",
-    "email": "other_responsible@example.com",
+    "email": "other_responsible@institution.example.com",
+    "title": "",
+    "cc_users": [],
+    "delegates": [],
+    "login_key_valid_until": null,
+    "login_key": null
+  }
+},
+{
+  "pk": 10,
+  "model": "evaluation.userprofile",
+  "fields": {
+    "username": "student_external",
+    "first_name": "student_external",
+    "last_name": "user",
+    "last_login": "2014-09-16T22:52:46.611",
+    "password": "pbkdf2_sha256$12000$oyxddVrECuLk$ayPgZNkTANvBcWbTvPGaR4NBlqPELpElG7C4M1UN210=",
+    "email": "student_external@external.com",
     "title": "",
     "cc_users": [],
     "delegates": [],
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
@@ -11,7 +11,7 @@ class TestResultsView(ViewTest):
 
     @classmethod
     def setUpTestData(cls):
-        mommy.make(UserProfile, username='evap')
+        mommy.make(UserProfile, username='evap', email="evap@institution.example.com")
 
 
 class TestResultsSemesterDetailView(ViewTest):
@@ -20,7 +20,7 @@ class TestResultsSemesterDetailView(ViewTest):
 
     @classmethod
     def setUpTestData(cls):
-        mommy.make(UserProfile, username='evap')
+        mommy.make(UserProfile, username='evap', email="evap@institution.example.com")
 
         cls.semester = mommy.make(Semester, id=1)
 
@@ -35,7 +35,7 @@ class TestResultsSemesterCourseDetailView(ViewTest):
     def setUpTestData(cls):
         cls.semester = mommy.make(Semester, id=2)
 
-        mommy.make(UserProfile, username='evap', groups=[Group.objects.get(name='Staff')])
+        mommy.make(UserProfile, username='evap', groups=[Group.objects.get(name='Staff')], email="evap@institution.example.com")
         contributor = UserProfile.objects.get(username="contributor")
         responsible = UserProfile.objects.get(username="responsible")
         # contributor = mommy.make(UserProfile, username='contributor')  # Add again when fixtures are removed
@@ -74,14 +74,15 @@ def test_wrong_state(self):
 
     def test_private_course(self):
         student = UserProfile.objects.get(username="student")
+        student_external = UserProfile.objects.get(username="student_external")
         contributor = UserProfile.objects.get(username="contributor")
         responsible = UserProfile.objects.get(username="responsible")
         other_responsible = UserProfile.objects.get(username="other_responsible")
         test1 = mommy.make(UserProfile, username="test1")
         test2 = mommy.make(UserProfile, username="test2")
-        mommy.make(UserProfile, username="random")
+        mommy.make(UserProfile, username="random", email="random@institution.example.com")
         degree = mommy.make(Degree)
-        private_course = mommy.make(Course, state='published', is_private=True, semester=self.semester, participants=[student, test1, test2], voters=[test1, test2], degrees=[degree])
+        private_course = mommy.make(Course, state='published', is_private=True, semester=self.semester, participants=[student, student_external, test1, test2], voters=[test1, test2], degrees=[degree])
         mommy.make(Contribution, course=private_course, contributor=responsible, can_edit=True, responsible=True, comment_visibility=Contribution.ALL_COMMENTS)
         mommy.make(Contribution, course=private_course, contributor=other_responsible, can_edit=True, responsible=True, comment_visibility=Contribution.ALL_COMMENTS)
         mommy.make(Contribution, course=private_course, contributor=contributor, can_edit=True)
@@ -99,6 +100,7 @@ def test_private_course(self):
         self.assertIn(private_course.name, page)
         page = self.app.get(url, user='evap')
         self.assertIn(private_course.name, page)
+        self.get_assert_403(url, 'student_external')  # external users can't see results semester view
 
         url = '/results/semester/%s/course/%s' % (self.semester.id, private_course.id)
         self.get_assert_403(url, "random")
@@ -107,6 +109,7 @@ def test_private_course(self):
         self.get_assert_200(url, "other_responsible")
         self.get_assert_200(url, "contributor")
         self.get_assert_200(url, "evap")
+        self.get_assert_200(url, "student_external")  # this external user participates in the course and can see the results
 
     def test_textanswer_visibility_for_responsible(self):
         page = self.app.get("/results/semester/1/course/1", user='responsible')
@@ -275,3 +278,7 @@ def test_textanswer_visibility_for_student(self):
         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_external(self):
+        # the external user does not participate in or contribute to the course and therefore can't see the results
+        self.get_assert_403("/results/semester/1/course/1", 'student_external')