Repository: e-valuation/EvaP

Issue:
Proportional rewards
Students should get a reward even if they missed an evaluation to still motivate them to finish the other evaluations.

- Once students have evaluated all courses they participated in, they receive `REWARD_POINTS_FULLY_EVALUATED` (default: 3) reward points (already implemented, setting is currently called `REWARD_POINTS_PER_SEMESTER`).
- If students didn't evaluate all their courses, they can still receive reward points once for all courses they participated in either the evaluation period ended or they already submitted their evaluation for the course.
    - They receive `REWARD_POINTS_TWO_THIRDS` (default: 2) reward points if they evaluated at least 66% of their courses.
    - They receive `REWARD_POINTS_ONE_THIRD` (default: 1) reward points if they evaluated at least 33% of their courses.


Discussion:
so, basically, `floor(evaluated_courses / total_courses * REWARD_POINTS_PER_SEMESTER)`?

if anything more elaborate is needed, i would propose something like `round(evaluated_courses / total_courses * REWARD_POINTS_PER_SEMESTER) + BONUS_WHEN_FULLY_EVALUATED`, gradually giving more points for more evaluated courses and a bonus for 100%ing the evaluation, or, for total freedom, a list of tuples like
```
# list of (fraction, points): when user evaluaties `fraction` of their courses, they get `points` points
REWARD_POINTS = [
    (1.0/3.0, 1), 
    (2.0/3.0, 2), 
    (3.0/3.0, 3), 
]
```
so the user can do whatever they want to do. having exactly three tiers seems both restricted and a bit arbitrary to me. it's not really simpler for the user and also not simpler in the code.
tuples are a perfectly customizable solution 👍

Resolving patch:
diff --git a/evap/rewards/models.py b/evap/rewards/models.py
--- a/evap/rewards/models.py
+++ b/evap/rewards/models.py
@@ -41,6 +41,12 @@ def redemptions_by_user(self):
         return redemptions_dict
 
 
+
+"""
+The two following objects handle reward point amounts. As reward points might be connected to monetary transactions,
+these objects may not be altered or deleted after creation.
+"""
+
 class RewardPointGranting(models.Model):
     user_profile = models.ForeignKey('evaluation.UserProfile', models.CASCADE, related_name="reward_point_grantings")
     semester = models.ForeignKey('evaluation.Semester', models.PROTECT, related_name="reward_point_grantings")
@@ -60,3 +66,4 @@ class RewardPointRedemption(models.Model):
 class SemesterActivation(models.Model):
     semester = models.OneToOneField('evaluation.Semester', models.CASCADE, related_name='rewards_active')
     is_active = models.BooleanField(default=False)
+
diff --git a/evap/rewards/tools.py b/evap/rewards/tools.py
--- a/evap/rewards/tools.py
+++ b/evap/rewards/tools.py
@@ -3,7 +3,9 @@
 from django.conf import settings
 from django.contrib import messages
 from django.db import models, transaction
+from django.db.models import Sum
 from django.utils.translation import ugettext as _
+from django.utils.translation import ngettext
 from django.dispatch import receiver
 from django.contrib.auth.decorators import login_required
 
@@ -59,27 +61,27 @@ def reward_points_of_user(user):
 def is_semester_activated(semester):
     return SemesterActivation.objects.filter(semester=semester, is_active=True).exists()
 
-def grant_reward_points(user, semester):
-    # grant reward points if all conditions are fulfilled
 
+def grant_reward_points_if_eligible(user, semester):
     if not can_user_use_reward_points(user):
-        return False
-    # has the semester been activated for reward points?
+        return 0, False
     if not is_semester_activated(semester):
-        return False
+        return 0, False
     # does the user have at least one required course in this semester?
     required_courses = Course.objects.filter(participants=user, semester=semester, is_required_for_reward=True)
     if not required_courses.exists():
-        return False
-    # does the user not participate in any more required courses in this semester?
-    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=user, semester=semester).exists():
-        return False
-    # grant reward points
-    RewardPointGranting.objects.create(user_profile=user, semester=semester, value=settings.REWARD_POINTS_PER_SEMESTER)
-    return True
+        return 0, False
+
+    # How many points have been granted to this user vs how many should they have (this semester)
+    granted_points = RewardPointGranting.objects.filter(user_profile=user, semester=semester).aggregate(Sum('value'))['value__sum'] or 0
+    progress = float(required_courses.filter(voters=user).count()) / float(required_courses.count())
+    target_points = max([points for threshold, points in settings.REWARD_POINTS if threshold <= progress], default=0)
+    missing_points = target_points - granted_points
+
+    if missing_points > 0:
+        RewardPointGranting.objects.create(user_profile=user, semester=semester, value=missing_points)
+        return missing_points, progress >= 1.0
+    return 0, False
 
 # Signal handlers
 
@@ -88,8 +90,18 @@ 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!"))
+    points_granted, completed_evaluation = grant_reward_points_if_eligible(request.user, semester)
+    if points_granted:
+        message = ngettext("You just earned {count} reward point for this semester.",
+                           "You just earned {count} reward points for this semester.", points_granted).format(count=points_granted)
+
+        if completed_evaluation:
+            message += " " + _("Thank you very much for evaluating all your courses.")
+        elif Course.objects.filter(participants=request.user, semester=semester, is_required_for_reward=True).exclude(state__in=['evaluated', 'reviewed', 'published'], voters=request.user).exists():
+            # at least one course exists that the user hasn't evaluated and is not past its evaluation period
+            message += " " + _("We're looking forward to receiving feedback for your other courses as well.")
+
+        messages.success(request, message)
 
 @receiver(models.signals.m2m_changed, sender=Course.participants.through)
 def grant_reward_points_after_delete(instance, action, reverse, pk_set, **kwargs):
@@ -102,15 +114,16 @@ def grant_reward_points_after_delete(instance, action, reverse, pk_set, **kwargs
             user = instance
 
             for semester in Semester.objects.filter(course__pk__in=pk_set):
-                if grant_reward_points(user, semester):
+                if grant_reward_points_if_eligible(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):
+                if grant_reward_points_if_eligible(user, course.semester):
                     affected.append(user)
 
         if affected:
             RewardPointGranting.granted_by_removal.send(sender=RewardPointGranting, users=affected)
+
diff --git a/evap/settings.py b/evap/settings.py
--- a/evap/settings.py
+++ b/evap/settings.py
@@ -45,8 +45,12 @@
 GRADE_PERCENTAGE = 0.8
 CONTRIBUTION_PERCENTAGE = 0.5
 
-# number of reward points to be given to a student once all courses of a semester have been voted for
-REWARD_POINTS_PER_SEMESTER = 3
+# number of reward points a student should have for a semester after evaluating the given fraction of courses.
+REWARD_POINTS = [
+    (1.0/3.0, 1), 
+    (2.0/3.0, 2), 
+    (3.0/3.0, 3), 
+]
 
 # days before end date to send reminder
 REMIND_X_DAYS_AHEAD_OF_END_DATE = [2, 0]


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,5 +1,5 @@
 from django.conf import settings
-from django.test import TestCase
+from django.test import TestCase, override_settings
 from django.urls import reverse
 
 from model_mommy import mommy
@@ -11,6 +11,11 @@
 from evap.rewards.tools import reward_points_of_user
 
 
+@override_settings(REWARD_POINTS=[
+    (1.0/3.0, 1),
+    (2.0/3.0, 2),
+    (3.0/3.0, 3),
+])
 class TestGrantRewardPoints(WebTest):
     csrf_checks = False
 
@@ -38,20 +43,34 @@ 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(reward_points_of_user(self.student), settings.REWARD_POINTS_PER_SEMESTER)
+        self.assertEqual(reward_points_of_user(self.student), 3)
 
     def test_semester_activated_not_all_courses(self):
         SemesterActivation.objects.create(semester=self.course.semester, is_active=True)
         mommy.make(Course, semester=self.course.semester, participants=[self.student])
         self.form.submit()
-        self.assertEqual(0, reward_points_of_user(self.student))
+        self.assertEqual(1, reward_points_of_user(self.student))
 
-    def test_already_got_points(self):
+    def test_already_got_grant_objects_but_points_missing(self):
         SemesterActivation.objects.create(semester=self.course.semester, is_active=True)
         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))
+        self.assertEqual(3, reward_points_of_user(self.student))
+        self.assertEqual(2, RewardPointGranting.objects.filter(user_profile=self.student, semester=self.course.semester).count())
 
+    def test_already_got_enough_points(self):
+        SemesterActivation.objects.create(semester=self.course.semester, is_active=True)
+        mommy.make(RewardPointGranting, user_profile=self.student, value=3, semester=self.course.semester)
+        self.form.submit()
+        self.assertEqual(3, reward_points_of_user(self.student))
+        self.assertEqual(1, RewardPointGranting.objects.filter(user_profile=self.student, semester=self.course.semester).count())
+
+
+@override_settings(REWARD_POINTS=[
+    (1.0/3.0, 1),
+    (2.0/3.0, 2),
+    (3.0/3.0, 3),
+])
 class TestGrantRewardPointsParticipationChange(TestCase):
     @classmethod
     def setUpTestData(cls):
@@ -64,9 +83,10 @@ def setUpTestData(cls):
     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)
+        self.assertEqual(reward_points_of_user(self.student), 3)
 
     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)
+        self.assertEqual(reward_points_of_user(self.student), 3)
+