diff --git a/core/models.py b/core/models.py index 20cbeb4b..7a9d3a46 100644 --- a/core/models.py +++ b/core/models.py @@ -529,13 +529,15 @@ class User(AbstractBaseUser): return False @cached_property - def can_create_subscription(self): - from club.models import Club + def can_create_subscription(self) -> bool: + from club.models import Membership - for club in Club.objects.filter(id__in=settings.SITH_CAN_CREATE_SUBSCRIPTIONS): - if club in self.clubs_with_rights: - return True - return False + return ( + Membership.objects.board() + .ongoing() + .filter(club_id__in=settings.SITH_CAN_CREATE_SUBSCRIPTIONS) + .exists() + ) @cached_property def is_launderette_manager(self): diff --git a/core/static/bundled/htmx-index.js b/core/static/bundled/htmx-index.js index 56edea4a..72fa5120 100644 --- a/core/static/bundled/htmx-index.js +++ b/core/static/bundled/htmx-index.js @@ -1,3 +1,5 @@ import htmx from "htmx.org"; +import "htmx-ext-response-targets/response-targets"; + Object.assign(window, { htmx }); diff --git a/core/static/core/style.scss b/core/static/core/style.scss index 664c1b99..dc9e85d7 100644 --- a/core/static/core/style.scss +++ b/core/static/core/style.scss @@ -262,8 +262,10 @@ a:not(.button) { font-weight: normal; color: white; padding: 9px 13px; + margin: 3px; border: none; text-decoration: none; + text-align: center; border-radius: 5px; &.btn-blue { @@ -367,6 +369,7 @@ a:not(.button) { .alert-aside { display: flex; flex-direction: column; + gap: 5px; } } diff --git a/package-lock.json b/package-lock.json index 05418a69..cb6483bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "d3-force-3d": "^3.0.5", "easymde": "^2.18.0", "glob": "^11.0.0", + "htmx-ext-response-targets": "^2.0.1", "htmx.org": "^2.0.3", "jquery": "^3.7.1", "jquery-ui": "^1.14.0", @@ -4140,6 +4141,11 @@ "node": ">= 0.4" } }, + "node_modules/htmx-ext-response-targets": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/htmx-ext-response-targets/-/htmx-ext-response-targets-2.0.1.tgz", + "integrity": "sha512-uCMw098+0xcrs7UW/s8l8hqj5wfOaVnVV7286cS+TNMNguo8fQpi/PEaZuT4VUysIiRcjj4pcTkuaP6Q9iJ3XA==" + }, "node_modules/htmx.org": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.3.tgz", diff --git a/package.json b/package.json index 2ca46967..7640a11f 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "easymde": "^2.18.0", "glob": "^11.0.0", "htmx.org": "^2.0.3", + "htmx-ext-response-targets": "^2.0.1", "jquery": "^3.7.1", "jquery-ui": "^1.14.0", "jquery.shorten": "^1.0.0", diff --git a/sas/widgets/select.py b/sas/widgets/select.py index 9f8676d3..1d124a27 100644 --- a/sas/widgets/select.py +++ b/sas/widgets/select.py @@ -1,9 +1,6 @@ from pydantic import TypeAdapter -from core.views.widgets.select import ( - AutoCompleteSelect, - AutoCompleteSelectMultiple, -) +from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple from sas.models import Album from sas.schemas import AlbumSchema diff --git a/subscription/forms.py b/subscription/forms.py index f627a6b7..7710825b 100644 --- a/subscription/forms.py +++ b/subscription/forms.py @@ -1,4 +1,5 @@ -import random +import secrets +from typing import Any from django import forms from django.core.exceptions import ValidationError @@ -22,67 +23,90 @@ class SelectionDateForm(forms.Form): class SubscriptionForm(forms.ModelForm): + def save(self, *args, **kwargs): + if self.errors: + # let django deal with the error messages + return super().save(*args, **kwargs) + + duration, user = self.instance.semester_duration, self.instance.member + self.instance.subscription_start = self.instance.compute_start( + duration=duration, user=user + ) + self.instance.subscription_end = self.instance.compute_end( + duration=duration, start=self.instance.subscription_start, user=user + ) + return super().save(*args, **kwargs) + + +class SubscriptionNewUserForm(SubscriptionForm): + """Form to create subscriptions with the user they belong to. + + Examples: + ```py + assert not User.objects.filter(email=request.POST.get("email")).exists() + form = SubscriptionNewUserForm(request.POST) + if form.is_valid(): + form.save() + + # now the user exists and is subscribed + user = User.objects.get(email=request.POST.get("email")) + assert user.is_subscribed + """ + + __user_fields = forms.fields_for_model( + User, + ["first_name", "last_name", "email", "date_of_birth"], + widgets={"date_of_birth": SelectDate}, + ) + first_name = __user_fields["first_name"] + last_name = __user_fields["last_name"] + email = __user_fields["email"] + date_of_birth = __user_fields["date_of_birth"] + + class Meta: + model = Subscription + fields = ["subscription_type", "payment_method", "location"] + + field_order = [ + "first_name", + "last_name", + "email", + "date_of_birth", + "subscription_type", + "payment_method", + "location", + ] + + def clean_email(self): + email = self.cleaned_data["email"] + if User.objects.filter(email=email).exists(): + raise ValidationError(_("A user with that email address already exists")) + return email + + def clean(self) -> dict[str, Any]: + member = User( + first_name=self.cleaned_data.get("first_name"), + last_name=self.cleaned_data.get("last_name"), + email=self.cleaned_data.get("email"), + date_of_birth=self.cleaned_data.get("date_of_birth"), + ) + member.generate_username() + member.set_password(secrets.token_urlsafe(nbytes=10)) + self.instance.member = member + return super().clean() + + def save(self, *args, **kwargs): + if self.errors: + # let django deal with the error messages + return super().save(*args, **kwargs) + self.instance.member.save() + return super().save(*args, **kwargs) + + +class SubscriptionExistingUserForm(SubscriptionForm): + """Form to add a subscription to an existing user.""" + class Meta: model = Subscription fields = ["member", "subscription_type", "payment_method", "location"] widgets = {"member": AutoCompleteSelectUser} - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["member"].required = False - self.fields |= forms.fields_for_model( - User, - fields=["first_name", "last_name", "email", "date_of_birth"], - widgets={"date_of_birth": SelectDate}, - ) - - def clean_member(self): - subscriber = self.cleaned_data.get("member") - if subscriber: - subscriber = User.objects.filter(id=subscriber.id).first() - return subscriber - - def clean(self): - cleaned_data = super().clean() - if ( - cleaned_data.get("member") is None - and "last_name" not in self.errors.as_data() - and "first_name" not in self.errors.as_data() - and "email" not in self.errors.as_data() - and "date_of_birth" not in self.errors.as_data() - ): - self.errors.pop("member", None) - if self.errors: - return cleaned_data - if User.objects.filter(email=cleaned_data.get("email")).first() is not None: - self.add_error( - "email", - ValidationError(_("A user with that email address already exists")), - ) - else: - u = User( - last_name=self.cleaned_data.get("last_name"), - first_name=self.cleaned_data.get("first_name"), - email=self.cleaned_data.get("email"), - date_of_birth=self.cleaned_data.get("date_of_birth"), - ) - u.generate_username() - u.set_password(str(random.randrange(1000000, 10000000))) - u.save() - cleaned_data["member"] = u - elif cleaned_data.get("member") is not None: - self.errors.pop("last_name", None) - self.errors.pop("first_name", None) - self.errors.pop("email", None) - self.errors.pop("date_of_birth", None) - if cleaned_data.get("member") is None: - # This should be handled here, - # but it is done in the Subscription model's clean method - # TODO investigate why! - raise ValidationError( - _( - "You must either choose an existing " - "user or create a new one properly" - ) - ) - return cleaned_data diff --git a/subscription/models.py b/subscription/models.py index 155acd5e..96d77d08 100644 --- a/subscription/models.py +++ b/subscription/models.py @@ -93,22 +93,23 @@ class Subscription(models.Model): def clean(self): today = localdate() - active_subscriptions = Subscription.objects.exclude(pk=self.pk).filter( - subscription_start__gte=today, subscription_end__lte=today + threshold = timedelta(weeks=settings.SITH_SUBSCRIPTION_END) + # a user may subscribe if : + # - he/she is not currently subscribed + # - its current subscription ends in less than a few weeks + overlapping_subscriptions = Subscription.objects.exclude(pk=self.pk).filter( + member=self.member, + subscription_start__lte=today, + subscription_end__gte=today + threshold, ) - for s in active_subscriptions: - if ( - s.is_valid_now() - and s.subscription_end - timedelta(weeks=settings.SITH_SUBSCRIPTION_END) - > date.today() - ): - raise ValidationError( - _("You can not subscribe many time for the same period") - ) + if overlapping_subscriptions.exists(): + raise ValidationError( + _("You can not subscribe many time for the same period") + ) @staticmethod def compute_start( - d: date | None = None, duration: int = 1, user: User | None = None + d: date | None = None, duration: int | float = 1, user: User | None = None ) -> date: """Computes the start date of the subscription. @@ -132,7 +133,7 @@ class Subscription(models.Model): @staticmethod def compute_end( - duration: int, start: date | None = None, user: User | None = None + duration: int | float, start: date | None = None, user: User | None = None ) -> date: """Compute the end date of the subscription. @@ -163,3 +164,19 @@ class Subscription(models.Model): def is_valid_now(self): return self.subscription_start <= date.today() <= self.subscription_end + + @property + def semester_duration(self) -> float: + """Duration of this subscription, in number of semester. + + Notes: + The `Subscription` object doesn't have to actually exist + in the database to access this property + + Examples: + ```py + subscription = Subscription(subscription_type="deux-semestres") + assert subscription.semester_duration == 2.0 + ``` + """ + return settings.SITH_SUBSCRIPTIONS[self.subscription_type]["duration"] diff --git a/subscription/templates/subscription/fragments/creation_form.jinja b/subscription/templates/subscription/fragments/creation_form.jinja new file mode 100644 index 00000000..92f4c1a3 --- /dev/null +++ b/subscription/templates/subscription/fragments/creation_form.jinja @@ -0,0 +1,10 @@ +
diff --git a/subscription/templates/subscription/fragments/creation_success.jinja b/subscription/templates/subscription/fragments/creation_success.jinja new file mode 100644 index 00000000..41b78ee9 --- /dev/null +++ b/subscription/templates/subscription/fragments/creation_success.jinja @@ -0,0 +1,27 @@ ++ + {% trans trimmed user=subscription.member.get_short_name(), type=subscription.subscription_type, end=subscription.subscription_end %} + {{ user }} received its new {{ type }} subscription. + It will be active until {{ end }} included. + {% endtrans %} +
+