Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reward point redemption events #2264

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
98 changes: 96 additions & 2 deletions evap/rewards/forms.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,108 @@
from contextlib import contextmanager
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.evaluation.models import UserProfile
from evap.rewards.models import RewardPointRedemption, RewardPointRedemptionEvent
from evap.rewards.tools import reward_points_of_user


class RewardPointRedemptionEventForm(forms.ModelForm):
class Meta:
model = RewardPointRedemptionEvent
fields = ("name", "date", "redeem_end_date")
fields = ("name", "date", "redeem_end_date", "step")

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"]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I got stuck for a second here on why we have this and the "You don't have enough reward points" check later, but I now got that the later one is for the sum of the redemptions.

Should we add a test that ensures these validations here are also run? Thinking cyber, I would want to check that we reject a request where we redeem 5 and -3 points when 4 are available (or similar) (and maybe also just 5 and just -3).

We can also do it in a follow up though, if you want

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also bothered me. iirc, we need the validator to get the correct drop-down choices in the frontend.

I wouldn't want to extensively test django validation. We can check that it is configured correctly (max value via the validator, min value via the form vield), but two simple assertions should be enough for that, I wouldn't start checking cross product stuff.

We can add an assertion to "our" validation logic that none of the values are negative.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, but I mean it's just three cases, I think that it is fair to do (I think that all three are interesting enough). In particular the case that the sum is bound, but the individual values are not, seems important to me.

Comment on lines +35 to +36
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@hansegucker where does "max" come from, and why do we need a separate MaxValueValidator? In the django docs on IntegerField, it sounds to me as if the field automatically does min- and max value validation if the values are set up, but it says that this should be done as min_value and max_value. We use min_value above, why not just max_value here?

Same for the StepValidator below, the IntegerField is supposed to have support for step_size.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have to differentiate on form fields and form widgets. max is a form widget attribute, max_value is a form field attribute. At this time, the form is already initialized and changing max_value doesn't have any effect anymore.


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)

def clean_event(self):
event = self.cleaned_data["event"]
if event.redeem_end_date < date.today():
raise ValidationError(_("Sorry, the deadline for this event expired already."))
return event


class BaseRewardPointRedemptionFormSet(forms.BaseFormSet):
def __init__(self, *args, user: UserProfile, **kwargs) -> None:
self.user = user
super().__init__(*args, **kwargs)
self.locked = False
hansegucker marked this conversation as resolved.
Show resolved Hide resolved

def get_form_kwargs(self, index):
kwargs = super().get_form_kwargs(index)
if not self.initial:
return kwargs
kwargs["initial"] = self.initial[index]
kwargs["initial"]["total_points_available"] = reward_points_of_user(self.user)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to be inside of formset.lock()? From my current understanding, the answer is yes, but it doesn't matter because normal usage doesn't trigger the locking edge cases and exploiting the race here will be caught by our final checks before creating the redemption objects. Is that correct?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't need to be inside the critical section. This only ends up in the MaxValueValidator in RewardPointRedemptionForm, which is only there for frontend/usability. For (security) correctness, only the total_points_available = reward_points_of_user(self.user) inside BaseRewardPointRedemptionFormSet.clean is relevant.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But just to be sure, these validators also run later when handling the POST data right? Just with maybe outdated bounds

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's just a django form field validator -- yes, it better be validated when calling is_valid.

The reward points being outdated could theoretically be an issue if a user was awarded points in the meantime. The MaxValueValidator allows providing a callable instead of a value. Maybe that allows shifting the computation to a time inside the locked phase -- but for just rendering the page, we never lock any rows anyway...

return kwargs

@contextmanager
def lock(self):
with transaction.atomic():
# 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())

self.locked = True
yield
self.locked = False
hansegucker marked this conversation as resolved.
Show resolved Hide resolved

def clean(self):
assert self.locked

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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To make sure the comment from the other thread isn't lost: I propose to add

Suggested change
total_points_redeemed = sum(form.cleaned_data["points"] for form in self.forms)
total_points_redeemed = sum(form.cleaned_data["points"] for form in self.forms)
assert all(form.cleaned_data["points"] > 0 for form in self.forms)

here

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We allow points to be zero if the user doesn't want to redeem points for an event. For values below 0, we validate the IntegerField with min_value=0. So, this additional check seems to be superfluous in my opinion.


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."))

def save(self) -> list[RewardPointRedemption]:
assert self.locked

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
)
20 changes: 20 additions & 0 deletions evap/rewards/migrations/0006_rewardpointredemptionevent_step.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Generated by Django 5.0.4 on 2024-08-05 19:49

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("rewards", "0005_alter_rewardpoint_minvalue"),
]

operations = [
migrations.AddField(
model_name="rewardpointredemptionevent",
name="step",
field=models.PositiveSmallIntegerField(
default=1, help_text="Only multiples of this step can be redeemed.", verbose_name="redemption step"
),
),
]
19 changes: 3 additions & 16 deletions evap/rewards/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,13 @@
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"))
redeem_end_date = models.DateField(verbose_name=_("redemption end date"))
step = models.PositiveSmallIntegerField(
verbose_name=_("redemption step"), help_text=_("Only multiples of this step can be redeemed."), default=1
)

@property
def can_delete(self):
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
78 changes: 33 additions & 45 deletions evap/rewards/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,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 @@ -49,79 +49,67 @@ 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_step = 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)
richardebeling marked this conversation as resolved.
Show resolved Hide resolved
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_step(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_step(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)
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
Loading