Repository: e-valuation/EvaP

Issue:
Reward point check on participation change
When the participations of users in a course are removed this might qualify them to receive reward points in case all their other participations in the active semester have already been evaluated.
This could happen by removing a participant in a course form or removing a course in the user's profile.

The platform should check for these cases and grant reward points accordingly.



Discussion:

Resolving patch:
diff --git a/evap/rewards/models.py b/evap/rewards/models.py
--- a/evap/rewards/models.py
+++ b/evap/rewards/models.py
@@ -1,6 +1,7 @@
 from collections import OrderedDict
 
 from django.utils.translation import ugettext_lazy as _
+from django.dispatch import Signal
 from django.db import models
 
 
@@ -46,6 +47,8 @@ class RewardPointGranting(models.Model):
     granting_time = models.DateTimeField(verbose_name=_("granting time"), auto_now_add=True)
     value = models.IntegerField(verbose_name=_("value"), default=0)
 
+    granted_by_removal = Signal(providing_args=['users'])
+
 
 class RewardPointRedemption(models.Model):
     user_profile = models.ForeignKey('evaluation.UserProfile', models.CASCADE, related_name="reward_point_redemptions")
diff --git a/evap/rewards/tools.py b/evap/rewards/tools.py
--- a/evap/rewards/tools.py
+++ b/evap/rewards/tools.py
@@ -2,12 +2,12 @@
 
 from django.conf import settings
 from django.contrib import messages
-from django.db import transaction
+from django.db import models, transaction
 from django.utils.translation import ugettext as _
 from django.dispatch import receiver
 from django.contrib.auth.decorators import login_required
 
-from evap.evaluation.models import Course
+from evap.evaluation.models import Semester, Course, UserProfile
 
 from evap.rewards.models import RewardPointGranting, RewardPointRedemption, RewardPointRedemptionEvent, \
                                 SemesterActivation, NoPointsSelected, NotEnoughPoints, RedemptionEventExpired
@@ -59,30 +59,58 @@ def reward_points_of_user(user):
 def is_semester_activated(semester):
     return SemesterActivation.objects.filter(semester=semester, is_active=True).exists()
 
-
-# Signal handlers
-
-@receiver(Course.course_evaluated)
-def grant_reward_points(sender, **kwargs):
+def grant_reward_points(user, semester):
     # grant reward points if all conditions are fulfilled
 
-    request = kwargs['request']
-    semester = kwargs['semester']
-    if not can_user_use_reward_points(request.user):
-        return
+    if not can_user_use_reward_points(user):
+        return False
     # has the semester been activated for reward points?
     if not is_semester_activated(semester):
-        return
+        return False
     # does the user have at least one required course in this semester?
-    required_courses = Course.objects.filter(participants=request.user, semester=semester, is_required_for_reward=True)
+    required_courses = Course.objects.filter(participants=user, semester=semester, is_required_for_reward=True)
     if not required_courses.exists():
-        return
+        return False
     # does the user not participate in any more required courses in this semester?
-    if required_courses.exclude(voters=request.user).exists():
-        return
+    if required_courses.exclude(voters=user).exists():
+        return False
     # did the user not already get reward points for this semester?
-    if RewardPointGranting.objects.filter(user_profile=request.user, semester=semester).exists():
-        return
+    if RewardPointGranting.objects.filter(user_profile=user, semester=semester).exists():
+        return False
     # grant reward points
-    RewardPointGranting.objects.create(user_profile=request.user, semester=semester, value=settings.REWARD_POINTS_PER_SEMESTER)
-    messages.success(request, _("You just have earned reward points for this semester because you evaluated all your courses. Thank you very much!"))
+    RewardPointGranting.objects.create(user_profile=user, semester=semester, value=settings.REWARD_POINTS_PER_SEMESTER)
+    return True
+
+# Signal handlers
+
+@receiver(Course.course_evaluated)
+def grant_reward_points_after_evaluate(sender, **kwargs):
+    request = kwargs['request']
+    semester = kwargs['semester']
+
+    if grant_reward_points(request.user, semester):
+        messages.success(request, _("You just have earned reward points for this semester because you evaluated all your courses. Thank you very much!"))
+
+@receiver(models.signals.m2m_changed, sender=Course.participants.through)
+def grant_reward_points_after_delete(instance, action, reverse, pk_set, **kwargs):
+    # if users do not need to evaluate a course anymore, they may have earned reward points
+    if action == 'post_remove':
+        affected = []
+
+        if reverse:
+            # a course got removed from a participant
+            user = instance
+
+            for semester in Semester.objects.filter(course__pk__in=pk_set):
+                if grant_reward_points(user, semester):
+                    affected = [user]
+        else:
+            # a participant got removed from a course
+            course = instance
+
+            for user in UserProfile.objects.filter(pk__in=pk_set):
+                if grant_reward_points(user, course.semester):
+                    affected.append(user)
+
+        if affected:
+            RewardPointGranting.granted_by_removal.send(sender=RewardPointGranting, users=affected)
diff --git a/evap/staff/views.py b/evap/staff/views.py
--- a/evap/staff/views.py
+++ b/evap/staff/views.py
@@ -7,6 +7,7 @@
 from django.conf import settings
 from django.contrib import messages
 from django.core.exceptions import PermissionDenied, SuspiciousOperation
+from django.dispatch import receiver
 from django.db import IntegrityError, transaction
 from django.db.models import BooleanField, Case, Count, ExpressionWrapper, IntegerField, Max, Prefetch, Q, Sum, When
 from django.forms import formset_factory
@@ -569,6 +570,13 @@ def course_edit(request, semester_id, course_id):
 
 @staff_required
 def helper_course_edit(request, semester, course):
+    @receiver(RewardPointGranting.granted_by_removal)
+    def notify_reward_points(users, **kwargs):
+        names = ", ".join('"{}"'.format(user.username) for user in users)
+        messages.info(request, ungettext("The removal of participants has granted {affected} user reward points: {names}.",
+            "The removal of participants has granted {affected} users reward points: {names}.",
+            len(users)).format(affected=len(users), names=names))
+
     InlineContributionFormset = inlineformset_factory(Course, Contribution, formset=ContributionFormSet, form=ContributionForm, extra=1)
 
     form = CourseForm(request.POST or None, instance=course)
@@ -1140,6 +1148,10 @@ def user_import(request):
 
 @staff_required
 def user_edit(request, user_id):
+    @receiver(RewardPointGranting.granted_by_removal)
+    def notify_reward_points(users, **kwargs):
+        messages.info(request, _('The removal of courses has granted the user "{}" reward points for the active semester.'.format(users[0].username)))
+
     user = get_object_or_404(UserProfile, id=user_id)
     form = UserForm(request.POST or None, request.FILES or None, instance=user)
 


Test patch:
diff --git a/evap/rewards/tests/test_tools.py b/evap/rewards/tests/test_tools.py
--- a/evap/rewards/tests/test_tools.py
+++ b/evap/rewards/tests/test_tools.py
@@ -1,4 +1,5 @@
 from django.conf import settings
+from django.test import TestCase
 from django.urls import reverse
 
 from model_mommy import mommy
@@ -37,7 +38,7 @@ def test_semester_not_activated(self):
     def test_everything_works(self):
         SemesterActivation.objects.create(semester=self.course.semester, is_active=True)
         self.form.submit()
-        self.assertEqual(settings.REWARD_POINTS_PER_SEMESTER, reward_points_of_user(self.student))
+        self.assertEqual(reward_points_of_user(self.student), settings.REWARD_POINTS_PER_SEMESTER)
 
     def test_semester_activated_not_all_courses(self):
         SemesterActivation.objects.create(semester=self.course.semester, is_active=True)
@@ -50,3 +51,22 @@ def test_already_got_points(self):
         mommy.make(RewardPointGranting, user_profile=self.student, value=0, semester=self.course.semester)
         self.form.submit()
         self.assertEqual(0, reward_points_of_user(self.student))
+
+class TestGrantRewardPointsParticipationChange(TestCase):
+    @classmethod
+    def setUpTestData(cls):
+        cls.course = mommy.make(Course)
+        already_evaluated = mommy.make(Course, semester=cls.course.semester)
+        SemesterActivation.objects.create(semester=cls.course.semester, is_active=True)
+        cls.student = mommy.make(UserProfile, username="student", email="foo@institution.example.com",
+            courses_participating_in=[cls.course, already_evaluated], courses_voted_for=[already_evaluated])
+
+    def test_participant_removed_from_course(self):
+        self.course.participants.remove(self.student)
+
+        self.assertEqual(reward_points_of_user(self.student), settings.REWARD_POINTS_PER_SEMESTER)
+
+    def test_course_removed_from_participant(self):
+        self.student.courses_participating_in.remove(self.course)
+
+        self.assertEqual(reward_points_of_user(self.student), settings.REWARD_POINTS_PER_SEMESTER)
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
@@ -13,6 +13,7 @@
                                    Questionnaire, Question, EmailTemplate, Degree, FaqSection, FaqQuestion, \
                                    RatingAnswerCounter
 from evap.evaluation.tests.tools import FuzzyInt, WebTest, ViewTest
+from evap.rewards.models import SemesterActivation
 from evap.staff.tools import generate_import_filename
 
 
@@ -139,6 +140,24 @@ def test_questionnaire_edit(self):
         form.submit()
         self.assertTrue(UserProfile.objects.filter(username='lfo9e7bmxp1xi').exists())
 
+    def test_reward_points_granting_message(self):
+        course = mommy.make(Course)
+        already_evaluated = mommy.make(Course, semester=course.semester)
+        SemesterActivation.objects.create(semester=course.semester, is_active=True)
+        student = mommy.make(UserProfile, email="foo@institution.example.com",
+            courses_participating_in=[course, already_evaluated], courses_voted_for=[already_evaluated])
+
+        page = self.get_assert_200(reverse('staff:user_edit', args=[student.pk]), 'staff')
+        form = page.forms['user-form']
+        form['courses_participating_in'] = [already_evaluated.pk]
+
+        page = form.submit().follow()
+        # fetch the user name, which became lowercased
+        student.refresh_from_db()
+
+        self.assertIn("Successfully updated user.", page)
+        self.assertIn("The removal of courses has granted the user &quot;{}&quot; reward points for the active semester.".format(student.username), page)
+
 
 class TestUserMergeSelectionView(ViewTest):
     url = "/staff/user/merge"
@@ -886,6 +905,40 @@ def test_remove_responsibility(self):
 
         self.assertIn("No responsible contributors found", page)
 
+    def test_participant_removal_reward_point_granting_message(self):
+        already_evaluated = mommy.make(Course, semester=self.course.semester)
+        SemesterActivation.objects.create(semester=self.course.semester, is_active=True)
+        other = mommy.make(UserProfile, courses_participating_in=[self.course])
+        student = mommy.make(UserProfile, email="foo@institution.example.com",
+            courses_participating_in=[self.course, already_evaluated], courses_voted_for=[already_evaluated])
+
+        page = self.app.get(reverse('staff:course_edit', args=[self.course.semester.pk, self.course.pk]), user='staff')
+
+        # remove a single participant
+        form = page.forms['course-form']
+        form['participants'] = [other.pk]
+        page = form.submit('operation', value='save').follow()
+
+        self.assertIn("The removal of participants has granted 1 user reward points: &quot;{}&quot;".format(student.username), page)
+
+    def test_remove_participants(self):
+        already_evaluated = mommy.make(Course, semester=self.course.semester)
+        SemesterActivation.objects.create(semester=self.course.semester, is_active=True)
+        student = mommy.make(UserProfile, courses_participating_in=[self.course])
+
+        for name in ["a", "b", "c", "d", "e"]:
+            mommy.make(UserProfile, username=name, email="{}@institution.example.com".format(name),
+            courses_participating_in=[self.course, already_evaluated], courses_voted_for=[already_evaluated])
+
+        page = self.app.get(reverse('staff:course_edit', args=[self.course.semester.pk, self.course.pk]), user='staff')
+
+        # remove five participants
+        form = page.forms['course-form']
+        form['participants'] = [student.pk]
+        page = form.submit('operation', value='save').follow()
+
+        self.assertIn("The removal of participants has granted 5 users reward points: &quot;a&quot;, &quot;b&quot;, &quot;c&quot;, &quot;d&quot;, &quot;e&quot;.", page)
+
     def test_last_modified_user(self):
         """
             Tests whether the button "Save and approve" does only change the