Repository: e-valuation/EvaP

Issue:
Add waiting time after reward point redemption
After reward points have been redeemed, the next redemption should not be able for a short amount of time to prevent users from accidentally redeeming the selected number of points twice by sending a double post request.

When a redemption is attempted within 30 seconds of the last one, the redemption should not be performed. Instead, an error should be shown that the user should wait a moment before redeeming more points.
Add waiting time after reward point redemption
After reward points have been redeemed, the next redemption should not be able for a short amount of time to prevent users from accidentally redeeming the selected number of points twice by sending a double post request.

When a redemption is attempted within 30 seconds of the last one, the redemption should not be performed. Instead, an error should be shown that the user should wait a moment before redeeming more points.


Discussion:
Edit: This comment is irrelevant: The issue is about client-side error of accidentally submitting multiple redemption requests without actually intending to, not about clients redeeming more points than they have available.

~Have we actually observed double-redemption?~

~We have code in place that should prevent that from happening in [rewards/tools.py:`save_redemptions`](https://github.com/e-valuation/EvaP/blob/main/evap/rewards/tools.py#L28). I cannot confidently claim that this code is correct in all situations, but after quickly going through [the docs](https://docs.djangoproject.com/en/4.0/ref/models/querysets/#select-for-update) again, I _think_ it does the right thing.~

~IMO, an unsychronized timeout wouldn't solve the issue: For two parallel requests, how could one request know that the other one is also concurrently accessing the timeout? I'm certain this can only be solved by using proper synchronization -- and then we don't need the timeout.~
Yes, we have observed them, that's why the issue was opened :)
The code we already have is fine for situations where the browser sends two posts almost at the same time for whatever reason. The problem which occurred here is that requests were sent about 10 seconds from each other (probably by clicking the button twice with a pause in between due to slow internet connection or any other reason). The two requests were correctly handled by the server, redeeming points unintentionally twice in a short period of time. Because it is very unlikely that this happens by intention, we should prevent two redemptions in a short amount of time between each other.
Ah, I see, so we want to gracefully handle an accidental repetition of partial redemptions by the user. Sorry, I misread the issue text.

I'm still not a fan of the timeout, since its amount is arbitrary. 30s would block me if I realize I forgot to set a higher count, but it wouldn't help if I have a tab open that shows an outdated redemption page, and thus think that I didn't redeem yet or something didn't work in my past attempt to redeem.

How about we add some more identifying information to the POST request, e.g. the reward points that the client thinks are redeemable, or the count of past redemptions by the user? This way, the server could check whether the request was issued from an up-to-date client, and deny processing it if it was outdated.
Edit: This comment is irrelevant: The issue is about client-side error of accidentally submitting multiple redemption requests without actually intending to, not about clients redeeming more points than they have available.

~Have we actually observed double-redemption?~

~We have code in place that should prevent that from happening in [rewards/tools.py:`save_redemptions`](https://github.com/e-valuation/EvaP/blob/main/evap/rewards/tools.py#L28). I cannot confidently claim that this code is correct in all situations, but after quickly going through [the docs](https://docs.djangoproject.com/en/4.0/ref/models/querysets/#select-for-update) again, I _think_ it does the right thing.~

~IMO, an unsychronized timeout wouldn't solve the issue: For two parallel requests, how could one request know that the other one is also concurrently accessing the timeout? I'm certain this can only be solved by using proper synchronization -- and then we don't need the timeout.~
Yes, we have observed them, that's why the issue was opened :)
The code we already have is fine for situations where the browser sends two posts almost at the same time for whatever reason. The problem which occurred here is that requests were sent about 10 seconds from each other (probably by clicking the button twice with a pause in between due to slow internet connection or any other reason). The two requests were correctly handled by the server, redeeming points unintentionally twice in a short period of time. Because it is very unlikely that this happens by intention, we should prevent two redemptions in a short amount of time between each other.
Ah, I see, so we want to gracefully handle an accidental repetition of partial redemptions by the user. Sorry, I misread the issue text.

I'm still not a fan of the timeout, since its amount is arbitrary. 30s would block me if I realize I forgot to set a higher count, but it wouldn't help if I have a tab open that shows an outdated redemption page, and thus think that I didn't redeem yet or something didn't work in my past attempt to redeem.

How about we add some more identifying information to the POST request, e.g. the reward points that the client thinks are redeemable, or the count of past redemptions by the user? This way, the server could check whether the request was issued from an up-to-date client, and deny processing it if it was outdated.

Resolving patch:
diff --git a/evap/rewards/models.py b/evap/rewards/models.py
--- a/evap/rewards/models.py
+++ b/evap/rewards/models.py
@@ -15,6 +15,10 @@ class NotEnoughPoints(Exception):
     """An attempt has been made to redeem more points than available."""
 
 
+class OutdatedRedemptionData(Exception):
+    """A redemption request has been sent with outdated data, e.g. when a request has been sent twice."""
+
+
 class RedemptionEventExpired(Exception):
     """An attempt has been made to redeem more points for an event whose redeem_end_date lies in the past."""
 
diff --git a/evap/rewards/tools.py b/evap/rewards/tools.py
--- a/evap/rewards/tools.py
+++ b/evap/rewards/tools.py
@@ -14,6 +14,7 @@
 from evap.rewards.models import (
     NoPointsSelected,
     NotEnoughPoints,
+    OutdatedRedemptionData,
     RedemptionEventExpired,
     RewardPointGranting,
     RewardPointRedemption,
@@ -23,11 +24,20 @@
 
 
 @transaction.atomic
-def save_redemptions(request, redemptions: Dict[int, int]):
+def save_redemptions(request, redemptions: Dict[int, int], previous_redeemed_points: int):
     # lock these rows to prevent race conditions
     list(request.user.reward_point_grantings.select_for_update())
     list(request.user.reward_point_redemptions.select_for_update())
 
+    # check consistent previous redeemed points
+    # do not validate reward points, to allow receiving points after page load
+    if previous_redeemed_points != redeemed_points_of_user(request.user):
+        raise OutdatedRedemptionData(
+            _(
+                "It appears that your browser sent multiple redemption requests. You can see all successful redemptions below."
+            )
+        )
+
     total_points_available = reward_points_of_user(request.user)
     total_points_redeemed = sum(redemptions.values())
 
@@ -60,6 +70,10 @@ def reward_points_of_user(user):
     return count
 
 
+def redeemed_points_of_user(user):
+    return RewardPointRedemption.objects.filter(user_profile=user).aggregate(Sum("value"))["value__sum"] or 0
+
+
 def is_semester_activated(semester):
     return SemesterActivation.objects.filter(semester=semester, is_active=True).exists()
 
diff --git a/evap/rewards/views.py b/evap/rewards/views.py
--- a/evap/rewards/views.py
+++ b/evap/rewards/views.py
@@ -16,6 +16,7 @@
 from evap.rewards.models import (
     NoPointsSelected,
     NotEnoughPoints,
+    OutdatedRedemptionData,
     RedemptionEventExpired,
     RewardPointGranting,
     RewardPointRedemption,
@@ -26,27 +27,34 @@
 from evap.staff.views import semester_view
 
 
+def redeem_reward_points(request):
+    redemptions = {}
+    try:
+        for key, value in request.POST.items():
+            if key.startswith("points-"):
+                event_id = int(key.rpartition("-")[2])
+                redemptions[event_id] = int(value)
+        previous_redeemed_points = int(request.POST["previous_redeemed_points"])
+    except (ValueError, KeyError, TypeError) as e:
+        raise BadRequest from e
+
+    try:
+        save_redemptions(request, redemptions, previous_redeemed_points)
+        messages.success(request, _("You successfully redeemed your points."))
+    except (NoPointsSelected, NotEnoughPoints, RedemptionEventExpired) as error:
+        messages.warning(request, error)
+        return 400
+    except OutdatedRedemptionData as error:
+        messages.error(request, error)
+        return 409
+    return 200
+
+
 @reward_user_required
 def index(request):
-    # pylint: disable=too-many-locals
     status = 200
     if request.method == "POST":
-        redemptions = {}
-        try:
-            for key, value in request.POST.items():
-                if key.startswith("points-"):
-                    event_id = int(key.rpartition("-")[2])
-                    redemptions[event_id] = int(value)
-        except ValueError as e:
-            raise BadRequest from e
-
-        try:
-            save_redemptions(request, redemptions)
-            messages.success(request, _("You successfully redeemed your points."))
-        except (NoPointsSelected, NotEnoughPoints, RedemptionEventExpired) as error:
-            messages.warning(request, error)
-            status = 400
-
+        status = redeem_reward_points(request)
     total_points_available = reward_points_of_user(request.user)
     reward_point_grantings = RewardPointGranting.objects.filter(user_profile=request.user)
     reward_point_redemptions = RewardPointRedemption.objects.filter(user_profile=request.user)
@@ -65,6 +73,7 @@ def index(request):
     template_data = dict(
         reward_point_actions=reward_point_actions,
         total_points_available=total_points_available,
+        total_points_spent=sum(redemption.value for redemption in reward_point_redemptions),
         events=events,
     )
     return render(request, "rewards_index.html", template_data, status=status)


Test patch:
diff --git a/evap/rewards/tests/test_views.py b/evap/rewards/tests/test_views.py
--- a/evap/rewards/tests/test_views.py
+++ b/evap/rewards/tests/test_views.py
@@ -13,7 +13,7 @@
     RewardPointRedemptionEvent,
     SemesterActivation,
 )
-from evap.rewards.tools import is_semester_activated, reward_points_of_user
+from evap.rewards.tools import is_semester_activated, redeemed_points_of_user, reward_points_of_user
 from evap.staff.tests.utils import WebTestStaffMode, WebTestStaffModeWith200Check
 
 
@@ -79,13 +79,46 @@ def test_redeem_points_for_expired_event(self):
         self.assertContains(response, "event expired already.", status_code=400)
         self.assertEqual(5, reward_points_of_user(self.student))
 
+    def post_redemption_request(self, redemption_params, additional_params=None, status=200):
+        if additional_params is None:
+            additional_params = {
+                "previous_redeemed_points": redeemed_points_of_user(self.student),
+            }
+        return self.app.post(
+            self.url, params={**redemption_params, **additional_params}, user=self.student, status=status
+        )
+
     def test_invalid_post_parameters(self):
-        self.app.post(self.url, params={"points-asd": 2}, user=self.student, status=400)
-        self.app.post(self.url, params={"points-": 2}, user=self.student, status=400)
-        self.app.post(self.url, params={f"points-{self.event1.pk}": ""}, user=self.student, status=400)
-        self.app.post(self.url, params={f"points-{self.event1.pk}": "asd"}, user=self.student, status=400)
+        self.post_redemption_request({"points-asd": 2}, status=400)
+        self.post_redemption_request({"points-": 2}, status=400)
+        self.post_redemption_request({f"points-{self.event1.pk}": ""}, status=400)
+        self.post_redemption_request({f"points-{self.event1.pk}": "asd"}, status=400)
+
+        # redemption without or with invalid point parameters
+        self.post_redemption_request(
+            redemption_params={f"points-{self.event1.pk}": 1}, additional_params={}, status=400
+        )
+        self.post_redemption_request(
+            redemption_params={f"points-{self.event1.pk}": 1},
+            additional_params={"previous_redeemed_points": "asd"},
+            status=400,
+        )
         self.assertFalse(RewardPointRedemption.objects.filter(user_profile=self.student).exists())
 
+        # now, a correct request succeeds
+        self.post_redemption_request({f"points-{self.event1.pk}": 2})
+
+    def test_inconsistent_previous_redemption_counts(self):
+        response1 = self.app.get(self.url, user=self.student)
+        form1 = response1.forms["reward-redemption-form"]
+        form1.set(f"points-{self.event1.pk}", 2)
+        response2 = self.app.get(self.url, user=self.student)
+        form2 = response2.forms["reward-redemption-form"]
+        form2.set(f"points-{self.event1.pk}", 2)
+        form1.submit()
+        form2.submit(status=409)
+        self.assertEqual(1, RewardPointRedemption.objects.filter(user_profile=self.student).count())
+
 
 class TestEventsView(WebTestStaffModeWith200Check):
     url = reverse("rewards:reward_point_redemption_events")