Skip to content

Commit 51df256

Browse files
committed
Use formset for redemption of reward points and support step validation
1 parent 11e730b commit 51df256

File tree

6 files changed

+140
-158
lines changed

6 files changed

+140
-158
lines changed

evap/rewards/forms.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
1+
from datetime import date
2+
13
from django import forms
4+
from django.core.exceptions import ValidationError
5+
from django.core.validators import MaxValueValidator, StepValueValidator
6+
from django.db import transaction
7+
from django.utils.translation import gettext as _
28

3-
from evap.rewards.models import RewardPointRedemptionEvent
9+
from evap.rewards.models import RewardPointRedemption, RewardPointRedemptionEvent
10+
from evap.rewards.tools import reward_points_of_user
411

512

613
class RewardPointRedemptionEventForm(forms.ModelForm):
@@ -12,3 +19,63 @@ def __init__(self, *args, **kwargs):
1219
super().__init__(*args, **kwargs)
1320
self.fields["date"].localize = True
1421
self.fields["redeem_end_date"].localize = True
22+
23+
24+
class RewardPointRedemptionForm(forms.Form):
25+
event = forms.ModelChoiceField(queryset=RewardPointRedemptionEvent.objects.all(), widget=forms.HiddenInput())
26+
points = forms.IntegerField(min_value=0, label="")
27+
28+
def __init__(self, *args, **kwargs):
29+
super().__init__(*args, **kwargs)
30+
if not self.initial:
31+
return
32+
33+
self.fields["points"].validators.append(MaxValueValidator(self.initial["total_points_available"]))
34+
self.fields["points"].widget.attrs["max"] = self.initial["total_points_available"]
35+
36+
self.fields["points"].validators.append(StepValueValidator(self.initial["event"].step))
37+
self.fields["points"].widget.attrs["step"] = self.initial["event"].step
38+
39+
if self.initial["event"].step > 1:
40+
self.fields["points"].help_text = _("multiples of {}").format(self.initial["event"].step)
41+
42+
43+
class BaseRewardPointRedemptionFormSet(forms.BaseFormSet):
44+
def __init__(self, *args, **kwargs):
45+
self.user = kwargs.pop("user")
46+
super().__init__(*args, **kwargs)
47+
48+
def clean(self):
49+
if any(self.errors):
50+
return
51+
52+
total_points_available = reward_points_of_user(self.user)
53+
total_points_redeemed = sum(form.cleaned_data["points"] for form in self.forms)
54+
55+
if total_points_redeemed <= 0:
56+
raise ValidationError(_("You cannot redeem 0 points."))
57+
58+
if total_points_redeemed > total_points_available:
59+
raise ValidationError(_("You don't have enough reward points."))
60+
61+
@transaction.atomic
62+
def save(self) -> list[RewardPointRedemption]:
63+
# lock these rows to prevent race conditions
64+
list(self.user.reward_point_grantings.select_for_update())
65+
list(self.user.reward_point_redemptions.select_for_update())
66+
67+
created = []
68+
for form in self.forms:
69+
points = form.cleaned_data["points"]
70+
if not points:
71+
continue
72+
redemption = RewardPointRedemption.objects.create(
73+
user_profile=self.user, value=points, event=form.cleaned_data["event"]
74+
)
75+
created.append(redemption)
76+
return created
77+
78+
79+
RewardPointRedemptionFormSet = forms.formset_factory(
80+
RewardPointRedemptionForm, formset=BaseRewardPointRedemptionFormSet, extra=0
81+
)

evap/rewards/models.py

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,6 @@
88
from evap.evaluation.models import Semester, UserProfile
99

1010

11-
class NoPointsSelectedError(Exception):
12-
"""An attempt has been made to redeem <= 0 points."""
13-
14-
15-
class NotEnoughPointsError(Exception):
16-
"""An attempt has been made to redeem more points than available."""
17-
18-
19-
class OutdatedRedemptionDataError(Exception):
20-
"""A redemption request has been sent with outdated data, e.g. when a request has been sent twice."""
21-
22-
23-
class RedemptionEventExpiredError(Exception):
24-
"""An attempt has been made to redeem more points for an event whose redeem_end_date lies in the past."""
25-
26-
2711
class RewardPointRedemptionEvent(models.Model):
2812
name = models.CharField(max_length=1024, verbose_name=_("event name"))
2913
date = models.DateField(verbose_name=_("event date"))

evap/rewards/templates/rewards_index.html

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
<form id="reward-redemption-form" action="#" method="POST" class="form-horizontal multiselect-form">
2323
{% csrf_token %}
2424

25+
{% include 'bootstrap_form_errors.html' with errors=formset.non_form_errors %}
26+
{{ formset.management_form }}
27+
2528
<input type="hidden" name="previous_redeemed_points" value="{{ total_points_spent }}">
2629
<table class="table table-striped table-vertically-aligned mb-3">
2730
<thead>
@@ -33,13 +36,14 @@
3336
</tr>
3437
</thead>
3538
<tbody>
36-
{% for event in events %}
39+
{% for form, event in forms %}
3740
<tr>
3841
<td>{{ event.date }}</td>
3942
<td>{{ event.name }}</td>
4043
<td>{{ event.redeem_end_date }}</td>
4144
<td>
42-
<input class="form-control" id="id_points-{{ event.id }}" name="points-{{ event.id }}" type="number" value="0" min="0" max="{{ total_points_available }}">
45+
{{ form.event }}
46+
{% include 'bootstrap_form_field_widget.html' with field=form.points %}
4347
</td>
4448
</tr>
4549
{% endfor %}

evap/rewards/tests/test_views.py

Lines changed: 34 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
RewardPointRedemptionEvent,
1414
SemesterActivation,
1515
)
16-
from evap.rewards.tools import is_semester_activated, redeemed_points_of_user, reward_points_of_user
16+
from evap.rewards.tools import is_semester_activated, reward_points_of_user
1717
from evap.staff.tests.utils import WebTestStaffMode, WebTestStaffModeWith200Check
1818

1919

@@ -50,81 +50,69 @@ def setUpTestData(cls):
5050
baker.make(RewardPointGranting, user_profile=cls.student, value=5)
5151
cls.event1 = baker.make(RewardPointRedemptionEvent, redeem_end_date=date.today() + timedelta(days=1))
5252
cls.event2 = baker.make(RewardPointRedemptionEvent, redeem_end_date=date.today() + timedelta(days=1))
53+
cls.event_with_multiple = baker.make(
54+
RewardPointRedemptionEvent, redeem_end_date=date.today() + timedelta(days=1), step=5
55+
)
5356

5457
def test_redeem_all_points(self):
5558
response = self.app.get(self.url, user=self.student)
5659
form = response.forms["reward-redemption-form"]
57-
form.set(f"points-{self.event1.pk}", 2)
58-
form.set(f"points-{self.event2.pk}", 3)
59-
response = form.submit()
60+
form.set("form-0-points", 2)
61+
form.set("form-1-points", 3)
62+
response = form.submit().follow()
6063
self.assertContains(response, "You successfully redeemed your points.")
6164
self.assertEqual(0, reward_points_of_user(self.student))
6265

6366
def test_redeem_too_many_points(self):
6467
response = self.app.get(self.url, user=self.student)
6568
form = response.forms["reward-redemption-form"]
66-
form.set(f"points-{self.event1.pk}", 3)
67-
form.set(f"points-{self.event2.pk}", 3)
68-
response = form.submit(status=400)
69-
self.assertContains(response, "have enough reward points.", status_code=400)
69+
form.set("form-0-points", 3)
70+
form.set("form-1-points", 3)
71+
response = form.submit()
72+
self.assertContains(response, "have enough reward points.")
7073
self.assertEqual(5, reward_points_of_user(self.student))
7174

7275
def test_redeem_zero_points(self):
7376
response = self.app.get(self.url, user=self.student)
7477
form = response.forms["reward-redemption-form"]
75-
form.set(f"points-{self.event1.pk}", 0)
76-
response = form.submit(status=400)
77-
self.assertContains(response, "cannot redeem 0 points.", status_code=400)
78+
response = form.submit()
79+
self.assertContains(response, "cannot redeem 0 points.")
80+
self.assertEqual(5, reward_points_of_user(self.student))
81+
82+
def test_redeem_multiple(self):
83+
response = self.app.get(self.url, user=self.student)
84+
form = response.forms["reward-redemption-form"]
85+
form.set("form-2-points", 5)
86+
response = form.submit().follow()
87+
self.assertContains(response, "You successfully redeemed your points.")
88+
self.assertEqual(0, reward_points_of_user(self.student))
89+
90+
def test_redeem_wrong_multiple(self):
91+
response = self.app.get(self.url, user=self.student)
92+
form = response.forms["reward-redemption-form"]
93+
form.set("form-2-points", 3)
94+
response = form.submit()
95+
self.assertContains(response, "this value is a multiple of step size 5.")
7896
self.assertEqual(5, reward_points_of_user(self.student))
7997

8098
def test_redeem_points_for_expired_event(self):
8199
"""Regression test for #846"""
82100
response = self.app.get(self.url, user=self.student)
83101
form = response.forms["reward-redemption-form"]
84-
form.set(f"points-{self.event2.pk}", 1)
102+
form.set("form-1-points", 1)
85103
RewardPointRedemptionEvent.objects.update(redeem_end_date=date.today() - timedelta(days=1))
86-
response = form.submit(status=400)
87-
self.assertContains(response, "event expired already.", status_code=400)
104+
response = form.submit()
88105
self.assertEqual(5, reward_points_of_user(self.student))
89106

90-
def post_redemption_request(self, redemption_params, additional_params=None, status=200):
91-
if additional_params is None:
92-
additional_params = {
93-
"previous_redeemed_points": redeemed_points_of_user(self.student),
94-
}
95-
return self.app.post(
96-
self.url, params={**redemption_params, **additional_params}, user=self.student, status=status
97-
)
98-
99-
def test_invalid_post_parameters(self):
100-
self.post_redemption_request({"points-asd": 2}, status=400)
101-
self.post_redemption_request({"points-": 2}, status=400)
102-
self.post_redemption_request({f"points-{self.event1.pk}": ""}, status=400)
103-
self.post_redemption_request({f"points-{self.event1.pk}": "asd"}, status=400)
104-
105-
# redemption without or with invalid point parameters
106-
self.post_redemption_request(
107-
redemption_params={f"points-{self.event1.pk}": 1}, additional_params={}, status=400
108-
)
109-
self.post_redemption_request(
110-
redemption_params={f"points-{self.event1.pk}": 1},
111-
additional_params={"previous_redeemed_points": "asd"},
112-
status=400,
113-
)
114-
self.assertFalse(RewardPointRedemption.objects.filter(user_profile=self.student).exists())
115-
116-
# now, a correct request succeeds
117-
self.post_redemption_request({f"points-{self.event1.pk}": 2})
118-
119107
def test_inconsistent_previous_redemption_counts(self):
120108
response1 = self.app.get(self.url, user=self.student)
121109
form1 = response1.forms["reward-redemption-form"]
122-
form1.set(f"points-{self.event1.pk}", 2)
110+
form1.set("form-1-points", 2)
123111
response2 = self.app.get(self.url, user=self.student)
124112
form2 = response2.forms["reward-redemption-form"]
125-
form2.set(f"points-{self.event1.pk}", 2)
113+
form2.set("form-1-points", 2)
126114
form1.submit()
127-
form2.submit(status=409)
115+
form2.submit(status=400)
128116
self.assertEqual(1, RewardPointRedemption.objects.filter(user_profile=self.student).count())
129117

130118

evap/rewards/tools.py

Lines changed: 2 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,13 @@
1-
from datetime import date
2-
31
from django.conf import settings
42
from django.contrib import messages
5-
from django.db import models, transaction
3+
from django.db import models
64
from django.db.models import Sum
75
from django.dispatch import receiver
8-
from django.shortcuts import get_object_or_404
96
from django.utils.translation import gettext as _
107
from django.utils.translation import ngettext
118

129
from evap.evaluation.models import Evaluation, Semester, UserProfile
13-
from evap.rewards.models import (
14-
NoPointsSelectedError,
15-
NotEnoughPointsError,
16-
OutdatedRedemptionDataError,
17-
RedemptionEventExpiredError,
18-
RewardPointGranting,
19-
RewardPointRedemption,
20-
RewardPointRedemptionEvent,
21-
SemesterActivation,
22-
)
23-
24-
25-
@transaction.atomic
26-
def save_redemptions(request, redemptions: dict[int, int], previous_redeemed_points: int):
27-
# lock these rows to prevent race conditions
28-
list(request.user.reward_point_grantings.select_for_update())
29-
list(request.user.reward_point_redemptions.select_for_update())
30-
31-
# check consistent previous redeemed points
32-
# do not validate reward points, to allow receiving points after page load
33-
if previous_redeemed_points != redeemed_points_of_user(request.user):
34-
raise OutdatedRedemptionDataError
35-
36-
total_points_available = reward_points_of_user(request.user)
37-
total_points_redeemed = sum(redemptions.values())
38-
39-
if total_points_redeemed <= 0:
40-
raise NoPointsSelectedError
41-
42-
if total_points_redeemed > total_points_available:
43-
raise NotEnoughPointsError
44-
45-
for event_id in redemptions:
46-
if redemptions[event_id] > 0:
47-
event = get_object_or_404(RewardPointRedemptionEvent, pk=event_id)
48-
if event.redeem_end_date < date.today():
49-
raise RedemptionEventExpiredError
50-
51-
RewardPointRedemption.objects.create(user_profile=request.user, value=redemptions[event_id], event=event)
10+
from evap.rewards.models import RewardPointGranting, RewardPointRedemption, SemesterActivation
5211

5312

5413
def can_reward_points_be_used_by(user):

0 commit comments

Comments
 (0)