diff --git a/evap/rewards/forms.py b/evap/rewards/forms.py index 0e68699681..9bb1a65a1b 100644 --- a/evap/rewards/forms.py +++ b/evap/rewards/forms.py @@ -1,14 +1,109 @@ +from contextlib import contextmanager +from datetime import date + from django import forms +from django.core.exceptions import ValidationError +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()) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.initial: + return + + help_text = _("multiples of {}").format(self.initial["event"].step) if self.initial["event"].step > 1 else "" + self.fields["points"] = forms.IntegerField( + min_value=0, + max_value=self.initial["total_points_available"], + step_size=self.initial["event"].step, + label="", + help_text=help_text, + ) + + 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: + super().__init__(*args, **kwargs) + self.user = user + self.locked = False + + 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) + 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 + try: + yield + finally: + self.locked = False + + 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) + assert all(form.cleaned_data["points"] >= 0 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.")) + + 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 +) diff --git a/evap/rewards/migrations/0006_rewardpointredemptionevent_step.py b/evap/rewards/migrations/0006_rewardpointredemptionevent_step.py new file mode 100644 index 0000000000..8453cb9da8 --- /dev/null +++ b/evap/rewards/migrations/0006_rewardpointredemptionevent_step.py @@ -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" + ), + ), + ] diff --git a/evap/rewards/models.py b/evap/rewards/models.py index da092d394a..5259d41b63 100644 --- a/evap/rewards/models.py +++ b/evap/rewards/models.py @@ -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): diff --git a/evap/rewards/templates/rewards_index.html b/evap/rewards/templates/rewards_index.html index d6ea795b03..5987f60e68 100644 --- a/evap/rewards/templates/rewards_index.html +++ b/evap/rewards/templates/rewards_index.html @@ -22,6 +22,9 @@