Skip to content

Commit

Permalink
Use formset for redemption of reward points and support step validation
Browse files Browse the repository at this point in the history
  • Loading branch information
hansegucker committed Aug 12, 2024
1 parent 11e730b commit 51df256
Show file tree
Hide file tree
Showing 6 changed files with 140 additions and 158 deletions.
69 changes: 68 additions & 1 deletion evap/rewards/forms.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
from datetime import date

from django import forms
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, StepValueValidator
from django.db import transaction
from django.utils.translation import gettext as _

from evap.rewards.models import RewardPointRedemptionEvent
from evap.rewards.models import RewardPointRedemption, RewardPointRedemptionEvent
from evap.rewards.tools import reward_points_of_user


class RewardPointRedemptionEventForm(forms.ModelForm):
Expand All @@ -12,3 +19,63 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["date"].localize = True
self.fields["redeem_end_date"].localize = True


class RewardPointRedemptionForm(forms.Form):
event = forms.ModelChoiceField(queryset=RewardPointRedemptionEvent.objects.all(), widget=forms.HiddenInput())
points = forms.IntegerField(min_value=0, label="")

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.initial:
return

self.fields["points"].validators.append(MaxValueValidator(self.initial["total_points_available"]))
self.fields["points"].widget.attrs["max"] = self.initial["total_points_available"]

self.fields["points"].validators.append(StepValueValidator(self.initial["event"].step))
self.fields["points"].widget.attrs["step"] = self.initial["event"].step

if self.initial["event"].step > 1:
self.fields["points"].help_text = _("multiples of {}").format(self.initial["event"].step)


class BaseRewardPointRedemptionFormSet(forms.BaseFormSet):
def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user")
super().__init__(*args, **kwargs)

def clean(self):
if any(self.errors):
return

total_points_available = reward_points_of_user(self.user)
total_points_redeemed = sum(form.cleaned_data["points"] for form in self.forms)

if total_points_redeemed <= 0:
raise ValidationError(_("You cannot redeem 0 points."))

if total_points_redeemed > total_points_available:
raise ValidationError(_("You don't have enough reward points."))

@transaction.atomic
def save(self) -> list[RewardPointRedemption]:
# lock these rows to prevent race conditions
list(self.user.reward_point_grantings.select_for_update())
list(self.user.reward_point_redemptions.select_for_update())

created = []
for form in self.forms:
points = form.cleaned_data["points"]
if not points:
continue
redemption = RewardPointRedemption.objects.create(
user_profile=self.user, value=points, event=form.cleaned_data["event"]
)
created.append(redemption)
return created


RewardPointRedemptionFormSet = forms.formset_factory(
RewardPointRedemptionForm, formset=BaseRewardPointRedemptionFormSet, extra=0
)
16 changes: 0 additions & 16 deletions evap/rewards/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,6 @@
from evap.evaluation.models import Semester, UserProfile


class NoPointsSelectedError(Exception):
"""An attempt has been made to redeem <= 0 points."""


class NotEnoughPointsError(Exception):
"""An attempt has been made to redeem more points than available."""


class OutdatedRedemptionDataError(Exception):
"""A redemption request has been sent with outdated data, e.g. when a request has been sent twice."""


class RedemptionEventExpiredError(Exception):
"""An attempt has been made to redeem more points for an event whose redeem_end_date lies in the past."""


class RewardPointRedemptionEvent(models.Model):
name = models.CharField(max_length=1024, verbose_name=_("event name"))
date = models.DateField(verbose_name=_("event date"))
Expand Down
8 changes: 6 additions & 2 deletions evap/rewards/templates/rewards_index.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
<form id="reward-redemption-form" action="#" method="POST" class="form-horizontal multiselect-form">
{% csrf_token %}

{% include 'bootstrap_form_errors.html' with errors=formset.non_form_errors %}
{{ formset.management_form }}

<input type="hidden" name="previous_redeemed_points" value="{{ total_points_spent }}">
<table class="table table-striped table-vertically-aligned mb-3">
<thead>
Expand All @@ -33,13 +36,14 @@
</tr>
</thead>
<tbody>
{% for event in events %}
{% for form, event in forms %}
<tr>
<td>{{ event.date }}</td>
<td>{{ event.name }}</td>
<td>{{ event.redeem_end_date }}</td>
<td>
<input class="form-control" id="id_points-{{ event.id }}" name="points-{{ event.id }}" type="number" value="0" min="0" max="{{ total_points_available }}">
{{ form.event }}
{% include 'bootstrap_form_field_widget.html' with field=form.points %}
</td>
</tr>
{% endfor %}
Expand Down
80 changes: 34 additions & 46 deletions evap/rewards/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
RewardPointRedemptionEvent,
SemesterActivation,
)
from evap.rewards.tools import is_semester_activated, redeemed_points_of_user, reward_points_of_user
from evap.rewards.tools import is_semester_activated, reward_points_of_user
from evap.staff.tests.utils import WebTestStaffMode, WebTestStaffModeWith200Check


Expand Down Expand Up @@ -50,81 +50,69 @@ def setUpTestData(cls):
baker.make(RewardPointGranting, user_profile=cls.student, value=5)
cls.event1 = baker.make(RewardPointRedemptionEvent, redeem_end_date=date.today() + timedelta(days=1))
cls.event2 = baker.make(RewardPointRedemptionEvent, redeem_end_date=date.today() + timedelta(days=1))
cls.event_with_multiple = baker.make(
RewardPointRedemptionEvent, redeem_end_date=date.today() + timedelta(days=1), step=5
)

def test_redeem_all_points(self):
response = self.app.get(self.url, user=self.student)
form = response.forms["reward-redemption-form"]
form.set(f"points-{self.event1.pk}", 2)
form.set(f"points-{self.event2.pk}", 3)
response = form.submit()
form.set("form-0-points", 2)
form.set("form-1-points", 3)
response = form.submit().follow()
self.assertContains(response, "You successfully redeemed your points.")
self.assertEqual(0, reward_points_of_user(self.student))

def test_redeem_too_many_points(self):
response = self.app.get(self.url, user=self.student)
form = response.forms["reward-redemption-form"]
form.set(f"points-{self.event1.pk}", 3)
form.set(f"points-{self.event2.pk}", 3)
response = form.submit(status=400)
self.assertContains(response, "have enough reward points.", status_code=400)
form.set("form-0-points", 3)
form.set("form-1-points", 3)
response = form.submit()
self.assertContains(response, "have enough reward points.")
self.assertEqual(5, reward_points_of_user(self.student))

def test_redeem_zero_points(self):
response = self.app.get(self.url, user=self.student)
form = response.forms["reward-redemption-form"]
form.set(f"points-{self.event1.pk}", 0)
response = form.submit(status=400)
self.assertContains(response, "cannot redeem 0 points.", status_code=400)
response = form.submit()
self.assertContains(response, "cannot redeem 0 points.")
self.assertEqual(5, reward_points_of_user(self.student))

def test_redeem_multiple(self):
response = self.app.get(self.url, user=self.student)
form = response.forms["reward-redemption-form"]
form.set("form-2-points", 5)
response = form.submit().follow()
self.assertContains(response, "You successfully redeemed your points.")
self.assertEqual(0, reward_points_of_user(self.student))

def test_redeem_wrong_multiple(self):
response = self.app.get(self.url, user=self.student)
form = response.forms["reward-redemption-form"]
form.set("form-2-points", 3)
response = form.submit()
self.assertContains(response, "this value is a multiple of step size 5.")
self.assertEqual(5, reward_points_of_user(self.student))

def test_redeem_points_for_expired_event(self):
"""Regression test for #846"""
response = self.app.get(self.url, user=self.student)
form = response.forms["reward-redemption-form"]
form.set(f"points-{self.event2.pk}", 1)
form.set("form-1-points", 1)
RewardPointRedemptionEvent.objects.update(redeem_end_date=date.today() - timedelta(days=1))
response = form.submit(status=400)
self.assertContains(response, "event expired already.", status_code=400)
response = form.submit()
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.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)
form1.set("form-1-points", 2)
response2 = self.app.get(self.url, user=self.student)
form2 = response2.forms["reward-redemption-form"]
form2.set(f"points-{self.event1.pk}", 2)
form2.set("form-1-points", 2)
form1.submit()
form2.submit(status=409)
form2.submit(status=400)
self.assertEqual(1, RewardPointRedemption.objects.filter(user_profile=self.student).count())


Expand Down
45 changes: 2 additions & 43 deletions evap/rewards/tools.py
Original file line number Diff line number Diff line change
@@ -1,54 +1,13 @@
from datetime import date

from django.conf import settings
from django.contrib import messages
from django.db import models, transaction
from django.db import models
from django.db.models import Sum
from django.dispatch import receiver
from django.shortcuts import get_object_or_404
from django.utils.translation import gettext as _
from django.utils.translation import ngettext

from evap.evaluation.models import Evaluation, Semester, UserProfile
from evap.rewards.models import (
NoPointsSelectedError,
NotEnoughPointsError,
OutdatedRedemptionDataError,
RedemptionEventExpiredError,
RewardPointGranting,
RewardPointRedemption,
RewardPointRedemptionEvent,
SemesterActivation,
)


@transaction.atomic
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 OutdatedRedemptionDataError

total_points_available = reward_points_of_user(request.user)
total_points_redeemed = sum(redemptions.values())

if total_points_redeemed <= 0:
raise NoPointsSelectedError

if total_points_redeemed > total_points_available:
raise NotEnoughPointsError

for event_id in redemptions:
if redemptions[event_id] > 0:
event = get_object_or_404(RewardPointRedemptionEvent, pk=event_id)
if event.redeem_end_date < date.today():
raise RedemptionEventExpiredError

RewardPointRedemption.objects.create(user_profile=request.user, value=redemptions[event_id], event=event)
from evap.rewards.models import RewardPointGranting, RewardPointRedemption, SemesterActivation


def can_reward_points_be_used_by(user):
Expand Down
Loading

0 comments on commit 51df256

Please sign in to comment.