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 @@ +
+ {% csrf_token %} + {{ form.as_p() }} + +
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 user=subscription.member %}Subscription created for {{ user }}{% endtrans %} +

+

+ + {% 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 %} +

+
+
+ + {% trans %}Go to user profile{% endtrans %} + + + {# We don't know if this fragment is displayed after creating a subscription + for a previously existing user or for a newly created one. + Thus, we don't know which form should be used to create another subscription + in this place. + Therefore, we reload the entire page. It just works. #} + {% trans %}Create another subscription{% endtrans %} + +
+
\ No newline at end of file diff --git a/subscription/templates/subscription/subscription.jinja b/subscription/templates/subscription/subscription.jinja index a26c7afc..b1657808 100644 --- a/subscription/templates/subscription/subscription.jinja +++ b/subscription/templates/subscription/subscription.jinja @@ -1,62 +1,38 @@ {% extends "core/base.jinja" %} +{% from "core/macros.jinja" import tabs %} + {% block title %} {% trans %}New subscription{% endtrans %} {% endblock %} +{# The following statics are bundled with our autocomplete select. + However, if one tries to swap a form by another, then the urls in script-once + and link-once disappear. + So we give them here. + If the aforementioned bug is resolved, you can remove this. #} +{% block additional_js %} + +{% endblock %} +{% block additional_css %} + + +{% endblock %} + +{% macro form_fragment(form_object, post_url) %} + {# Include the form fragment inside a with block, + in order to inject the right form in the right place #} + {% with form=form_object, post_url=post_url %} + {% include "subscription/fragments/creation_form.jinja" %} + {% endwith %} +{% endmacro %} + {% block content %}

{% trans %}New subscription{% endtrans %}

-
-
- {% csrf_token %} - {{ form.non_field_errors() }} -

{{ form.member.errors }} {{ form.member }}

-
-

{{ form.first_name.errors }} {{ form.first_name }}

-

{{ form.last_name.errors }} {{ form.last_name }}

-

{{ form.email.errors }} {{ form.email }}

-

{{ form.date_of_birth.errors }} {{ form.date_of_birth }}

-
-

{{ form.subscription_type.errors }} {{ form.subscription_type }}

-

{{ form.payment_method.errors }} {{ - form.payment_method }}

-

{% trans %}Eboutic is reserved to specific users. In doubt, don't use it.{% endtrans %}

-

{{ form.location.errors }} {{ form.location }}

-

-
-{% endblock %} - -{% block script %} - {{ super() }} - +
+ {{ tabs([ + (_("Existing member"), form_fragment(existing_user_form, existing_user_post_url)), + (_("New member"), form_fragment(new_user_form, new_user_post_url)), + ]) }} +
{% endblock %} diff --git a/subscription/urls.py b/subscription/urls.py index 6bd1fa65..47dbf21e 100644 --- a/subscription/urls.py +++ b/subscription/urls.py @@ -15,10 +15,31 @@ from django.urls import path -from subscription.views import NewSubscription, SubscriptionsStatsView +from subscription.views import ( + CreateSubscriptionExistingUserFragment, + CreateSubscriptionNewUserFragment, + NewSubscription, + SubscriptionCreatedFragment, + SubscriptionsStatsView, +) urlpatterns = [ # Subscription views path("", NewSubscription.as_view(), name="subscription"), + path( + "fragment/existing-user/", + CreateSubscriptionExistingUserFragment.as_view(), + name="fragment-existing-user", + ), + path( + "fragment/new-user/", + CreateSubscriptionNewUserFragment.as_view(), + name="fragment-new-user", + ), + path( + "fragment//creation-success", + SubscriptionCreatedFragment.as_view(), + name="creation-success", + ), path("stats/", SubscriptionsStatsView.as_view(), name="stats"), ] diff --git a/subscription/views.py b/subscription/views.py index a6ecc294..3e8cf0f1 100644 --- a/subscription/views.py +++ b/subscription/views.py @@ -13,85 +13,94 @@ # # -import secrets - -from django import forms from django.conf import settings +from django.contrib.auth.mixins import UserPassesTestMixin from django.core.exceptions import PermissionDenied -from django.urls import reverse_lazy -from django.views.generic.edit import CreateView, FormView +from django.urls import reverse, reverse_lazy +from django.utils.timezone import localdate +from django.views.generic import CreateView, DetailView, TemplateView +from django.views.generic.edit import FormView -from subscription.forms import SelectionDateForm, SubscriptionForm +from subscription.forms import ( + SelectionDateForm, + SubscriptionExistingUserForm, + SubscriptionNewUserForm, +) from subscription.models import Subscription -class NewSubscription(CreateView): +class CanCreateSubscriptionMixin(UserPassesTestMixin): + def test_func(self): + return self.request.user.can_create_subscription + + +class NewSubscription(CanCreateSubscriptionMixin, TemplateView): template_name = "subscription/subscription.jinja" - form_class = SubscriptionForm - def dispatch(self, request, *arg, **kwargs): - if request.user.can_create_subscription: - return super().dispatch(request, *arg, **kwargs) - raise PermissionDenied + def get_context_data(self, **kwargs): + return super().get_context_data(**kwargs) | { + "existing_user_form": SubscriptionExistingUserForm(), + "new_user_form": SubscriptionNewUserForm(), + "existing_user_post_url": reverse("subscription:fragment-existing-user"), + "new_user_post_url": reverse("subscription:fragment-new-user"), + } - def get_initial(self): - if "member" in self.request.GET: - return { - "member": self.request.GET["member"], - "subscription_type": "deux-semestres", - } - return {"subscription_type": "deux-semestres"} - def form_valid(self, form): - form.instance.subscription_start = Subscription.compute_start( - duration=settings.SITH_SUBSCRIPTIONS[form.instance.subscription_type][ - "duration" - ], - user=form.instance.member, +class CreateSubscriptionFragment(CanCreateSubscriptionMixin, CreateView): + template_name = "subscription/fragments/creation_form.jinja" + + def get_success_url(self): + return reverse( + "subscription:creation-success", kwargs={"subscription_id": self.object.id} ) - form.instance.subscription_end = Subscription.compute_end( - duration=settings.SITH_SUBSCRIPTIONS[form.instance.subscription_type][ - "duration" - ], - start=form.instance.subscription_start, - user=form.instance.member, - ) - return super().form_valid(form) + + +class CreateSubscriptionExistingUserFragment(CreateSubscriptionFragment): + """Create a subscription for a user who already exists.""" + + form_class = SubscriptionExistingUserForm + extra_context = {"post_url": reverse_lazy("subscription:fragment-existing-user")} + + +class CreateSubscriptionNewUserFragment(CreateSubscriptionFragment): + """Create a subscription for a user who already exists.""" + + form_class = SubscriptionNewUserForm + extra_context = {"post_url": reverse_lazy("subscription:fragment-new-user")} + + +class SubscriptionCreatedFragment(CanCreateSubscriptionMixin, DetailView): + template_name = "subscription/fragments/creation_success.jinja" + model = Subscription + pk_url_kwarg = "subscription_id" + context_object_name = "subscription" class SubscriptionsStatsView(FormView): template_name = "subscription/stats.jinja" form_class = SelectionDateForm + success_url = reverse_lazy("subscriptions:stats") def dispatch(self, request, *arg, **kwargs): - import datetime - - self.start_date = datetime.datetime.today() + self.start_date = localdate() self.end_date = self.start_date - res = super().dispatch(request, *arg, **kwargs) if request.user.is_root or request.user.is_board_member: - return res + return super().dispatch(request, *arg, **kwargs) raise PermissionDenied def post(self, request, *args, **kwargs): self.form = self.get_form() self.start_date = self.form["start_date"] self.end_date = self.form["end_date"] - res = super().post(request, *args, **kwargs) - if request.user.is_root or request.user.is_board_member: - return res - raise PermissionDenied + return super().post(request, *args, **kwargs) def get_initial(self): - init = { + return { "start_date": self.start_date.strftime("%Y-%m-%d %H:%M:%S"), "end_date": self.end_date.strftime("%Y-%m-%d %H:%M:%S"), } - return init def get_context_data(self, **kwargs): - from subscription.models import Subscription - kwargs = super().get_context_data(**kwargs) kwargs["subscriptions_total"] = Subscription.objects.filter( subscription_end__gte=self.end_date, subscription_start__lte=self.start_date @@ -100,6 +109,3 @@ class SubscriptionsStatsView(FormView): kwargs["payment_types"] = settings.SITH_COUNTER_PAYMENT_METHOD kwargs["locations"] = settings.SITH_SUBSCRIPTION_LOCATIONS return kwargs - - def get_success_url(self, **kwargs): - return reverse_lazy("subscriptions:stats") diff --git a/vite.config.mts b/vite.config.mts index 564781f3..955590b7 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -85,6 +85,7 @@ export default defineConfig((config: UserConfig) => { inject({ // biome-ignore lint/style/useNamingConvention: that's how it's called Alpine: "alpinejs", + htmx: "htmx.org", }), viteStaticCopy({ targets: [