-
Notifications
You must be signed in to change notification settings - Fork 146
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
base: main
Are you sure you want to change the base?
Changes from all commits
e4c3bf5
77dd7b5
6a8d0a4
9743c64
10e11f9
86f24bf
1fbe170
fa90080
efb90e8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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"] | ||||||||
Comment on lines
+35
to
+36
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Same for the StepValidator below, the IntegerField is supposed to have support for There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does this need to be inside of There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 The reward points being outdated could theoretically be an issue if a user was awarded points in the meantime. The |
||||||||
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) | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
here There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||||||||
) |
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" | ||
), | ||
), | ||
] |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.