Split SubscriptionForm into SubscriptionNewUserForm and SubscriptionExistingUserForm

This commit is contained in:
imperosol 2024-11-19 00:41:49 +01:00
parent 75406f7b58
commit d2d639e5f6
14 changed files with 280 additions and 187 deletions

View File

@ -529,13 +529,15 @@ class User(AbstractBaseUser):
return False return False
@cached_property @cached_property
def can_create_subscription(self): def can_create_subscription(self) -> bool:
from club.models import Club from club.models import Membership
for club in Club.objects.filter(id__in=settings.SITH_CAN_CREATE_SUBSCRIPTIONS): return (
if club in self.clubs_with_rights: Membership.objects.board()
return True .ongoing()
return False .filter(club_id__in=settings.SITH_CAN_CREATE_SUBSCRIPTIONS)
.exists()
)
@cached_property @cached_property
def is_launderette_manager(self): def is_launderette_manager(self):

View File

@ -1,3 +1,5 @@
import htmx from "htmx.org"; import htmx from "htmx.org";
import "htmx-ext-response-targets/response-targets";
Object.assign(window, { htmx }); Object.assign(window, { htmx });

View File

@ -262,8 +262,10 @@ a:not(.button) {
font-weight: normal; font-weight: normal;
color: white; color: white;
padding: 9px 13px; padding: 9px 13px;
margin: 3px;
border: none; border: none;
text-decoration: none; text-decoration: none;
text-align: center;
border-radius: 5px; border-radius: 5px;
&.btn-blue { &.btn-blue {
@ -367,6 +369,7 @@ a:not(.button) {
.alert-aside { .alert-aside {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 5px;
} }
} }

6
package-lock.json generated
View File

@ -22,6 +22,7 @@
"d3-force-3d": "^3.0.5", "d3-force-3d": "^3.0.5",
"easymde": "^2.18.0", "easymde": "^2.18.0",
"glob": "^11.0.0", "glob": "^11.0.0",
"htmx-ext-response-targets": "^2.0.1",
"htmx.org": "^2.0.3", "htmx.org": "^2.0.3",
"jquery": "^3.7.1", "jquery": "^3.7.1",
"jquery-ui": "^1.14.0", "jquery-ui": "^1.14.0",
@ -4140,6 +4141,11 @@
"node": ">= 0.4" "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": { "node_modules/htmx.org": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.3.tgz", "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.3.tgz",

View File

@ -47,6 +47,7 @@
"easymde": "^2.18.0", "easymde": "^2.18.0",
"glob": "^11.0.0", "glob": "^11.0.0",
"htmx.org": "^2.0.3", "htmx.org": "^2.0.3",
"htmx-ext-response-targets": "^2.0.1",
"jquery": "^3.7.1", "jquery": "^3.7.1",
"jquery-ui": "^1.14.0", "jquery-ui": "^1.14.0",
"jquery.shorten": "^1.0.0", "jquery.shorten": "^1.0.0",

View File

@ -1,9 +1,6 @@
from pydantic import TypeAdapter from pydantic import TypeAdapter
from core.views.widgets.select import ( from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple
AutoCompleteSelect,
AutoCompleteSelectMultiple,
)
from sas.models import Album from sas.models import Album
from sas.schemas import AlbumSchema from sas.schemas import AlbumSchema

View File

@ -1,4 +1,5 @@
import random import secrets
from typing import Any
from django import forms from django import forms
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@ -22,67 +23,90 @@ class SelectionDateForm(forms.Form):
class SubscriptionForm(forms.ModelForm): 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: class Meta:
model = Subscription model = Subscription
fields = ["member", "subscription_type", "payment_method", "location"] fields = ["member", "subscription_type", "payment_method", "location"]
widgets = {"member": AutoCompleteSelectUser} 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

View File

@ -93,22 +93,23 @@ class Subscription(models.Model):
def clean(self): def clean(self):
today = localdate() today = localdate()
active_subscriptions = Subscription.objects.exclude(pk=self.pk).filter( threshold = timedelta(weeks=settings.SITH_SUBSCRIPTION_END)
subscription_start__gte=today, subscription_end__lte=today # 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 overlapping_subscriptions.exists():
if ( raise ValidationError(
s.is_valid_now() _("You can not subscribe many time for the same period")
and s.subscription_end - timedelta(weeks=settings.SITH_SUBSCRIPTION_END) )
> date.today()
):
raise ValidationError(
_("You can not subscribe many time for the same period")
)
@staticmethod @staticmethod
def compute_start( 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: ) -> date:
"""Computes the start date of the subscription. """Computes the start date of the subscription.
@ -132,7 +133,7 @@ class Subscription(models.Model):
@staticmethod @staticmethod
def compute_end( 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: ) -> date:
"""Compute the end date of the subscription. """Compute the end date of the subscription.
@ -163,3 +164,19 @@ class Subscription(models.Model):
def is_valid_now(self): def is_valid_now(self):
return self.subscription_start <= date.today() <= self.subscription_end 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"]

View File

@ -0,0 +1,10 @@
<form
hx-post="{{ post_url }}"
hx-target="this"
hx-disabled-elt="find input[type='submit']"
hx-swap="outerHTML"
>
{% csrf_token %}
{{ form.as_p() }}
<input type="submit" value="{% trans %}Save{% endtrans %}">
</form>

View File

@ -0,0 +1,27 @@
<div class="alert alert-green">
<div class="alert-main">
<h3 class="alert-title">
{% trans user=subscription.member %}Subscription created for {{ user }}{% endtrans %}
</h3>
<p>
{% 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 %}
</p>
</div>
<div class="alert-aside">
<a class="btn btn-blue" href="{{ subscription.member.get_absolute_url() }}">
{% trans %}Go to user profile{% endtrans %}
</a>
<a class="btn btn-grey" href="{{ url("subscription:subscription") }}">
{# 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 %}
</a>
</div>
</div>

View File

@ -1,62 +1,38 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% from "core/macros.jinja" import tabs %}
{% block title %} {% block title %}
{% trans %}New subscription{% endtrans %} {% trans %}New subscription{% endtrans %}
{% endblock %} {% 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 %}
<script type="module" defer src="{{ static("bundled/core/components/ajax-select-index.ts") }}"></script>
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("bundled/core/components/ajax-select-index.css") }}">
<link rel="stylesheet" href="{{ static("core/components/ajax-select.scss") }}">
{% 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 %} {% block content %}
<h3>{% trans %}New subscription{% endtrans %}</h3> <h3>{% trans %}New subscription{% endtrans %}</h3>
<div id="user_info"></div> <div id="subscription-form" hx-ext="response-targets">
<form action="" method="post" id="subscription_form"> {{ tabs([
{% csrf_token %} (_("Existing member"), form_fragment(existing_user_form, existing_user_post_url)),
{{ form.non_field_errors() }} (_("New member"), form_fragment(new_user_form, new_user_post_url)),
<p>{{ form.member.errors }}<label for="{{ form.member.name }}">{{ form.member.label }}</label> {{ form.member }}</p> ]) }}
<div id="new_member"> </div>
<p>{{ form.first_name.errors }}<label for="{{ form.first_name.name }}">{{ form.first_name.label }}</label> {{ form.first_name }}</p>
<p>{{ form.last_name.errors }}<label for="{{ form.last_name.name }}">{{ form.last_name.label }}</label> {{ form.last_name }}</p>
<p>{{ form.email.errors }}<label for="{{ form.email.name }}">{{ form.email.label }}</label> {{ form.email }}</p>
<p>{{ form.date_of_birth.errors }}<label for="{{ form.date_of_birth.name }}">{{ form.date_of_birth.label}}</label> {{ form.date_of_birth }}</p>
</div>
<p>{{ form.subscription_type.errors }}<label for="{{ form.subscription_type.name }}">{{ form.subscription_type.label }}</label> {{ form.subscription_type }}</p>
<p>{{ form.payment_method.errors }}<label for="{{ form.payment_method.name }}">{{ form.payment_method.label }}</label> {{
form.payment_method }}</p>
<p>{% trans %}Eboutic is reserved to specific users. In doubt, don't use it.{% endtrans %}</p>
<p>{{ form.location.errors }}<label for="{{ form.location.name }}">{{ form.location.label }}</label> {{ form.location }}</p>
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form>
{% endblock %}
{% block script %}
{{ super() }}
<script type="text/javascript" charset="utf-8">
$( function() {
select = $("#id_member");
member_block = $("#subscription_form #new_member");
user_info = $("#user_info");
function display_new_member() {
if (select.val()) {
member_block.hide();
member_block.children().each(function() {
$(this).children().each(function() {
$(this).removeAttr('required');
});
});
user_info.load("/user/"+select.val()+"/mini");
user_info.show();
} else {
member_block.show();
member_block.children().each(function() {
$(this).children().each(function() {
$(this).prop('required', true);
});
});
user_info.empty();
user_info.hide();
}
}
select.on("change", display_new_member);
display_new_member();
} );
</script>
{% endblock %} {% endblock %}

View File

@ -15,10 +15,31 @@
from django.urls import path from django.urls import path
from subscription.views import NewSubscription, SubscriptionsStatsView from subscription.views import (
CreateSubscriptionExistingUserFragment,
CreateSubscriptionNewUserFragment,
NewSubscription,
SubscriptionCreatedFragment,
SubscriptionsStatsView,
)
urlpatterns = [ urlpatterns = [
# Subscription views # Subscription views
path("", NewSubscription.as_view(), name="subscription"), 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/<int:subscription_id>/creation-success",
SubscriptionCreatedFragment.as_view(),
name="creation-success",
),
path("stats/", SubscriptionsStatsView.as_view(), name="stats"), path("stats/", SubscriptionsStatsView.as_view(), name="stats"),
] ]

View File

@ -13,85 +13,94 @@
# #
# #
import secrets
from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import UserPassesTestMixin
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.urls import reverse_lazy from django.urls import reverse, reverse_lazy
from django.views.generic.edit import CreateView, FormView 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 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" template_name = "subscription/subscription.jinja"
form_class = SubscriptionForm
def dispatch(self, request, *arg, **kwargs): def get_context_data(self, **kwargs):
if request.user.can_create_subscription: return super().get_context_data(**kwargs) | {
return super().dispatch(request, *arg, **kwargs) "existing_user_form": SubscriptionExistingUserForm(),
raise PermissionDenied "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): class CreateSubscriptionFragment(CanCreateSubscriptionMixin, CreateView):
form.instance.subscription_start = Subscription.compute_start( template_name = "subscription/fragments/creation_form.jinja"
duration=settings.SITH_SUBSCRIPTIONS[form.instance.subscription_type][
"duration" def get_success_url(self):
], return reverse(
user=form.instance.member, "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" class CreateSubscriptionExistingUserFragment(CreateSubscriptionFragment):
], """Create a subscription for a user who already exists."""
start=form.instance.subscription_start,
user=form.instance.member, form_class = SubscriptionExistingUserForm
) extra_context = {"post_url": reverse_lazy("subscription:fragment-existing-user")}
return super().form_valid(form)
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): class SubscriptionsStatsView(FormView):
template_name = "subscription/stats.jinja" template_name = "subscription/stats.jinja"
form_class = SelectionDateForm form_class = SelectionDateForm
success_url = reverse_lazy("subscriptions:stats")
def dispatch(self, request, *arg, **kwargs): def dispatch(self, request, *arg, **kwargs):
import datetime self.start_date = localdate()
self.start_date = datetime.datetime.today()
self.end_date = self.start_date self.end_date = self.start_date
res = super().dispatch(request, *arg, **kwargs)
if request.user.is_root or request.user.is_board_member: if request.user.is_root or request.user.is_board_member:
return res return super().dispatch(request, *arg, **kwargs)
raise PermissionDenied raise PermissionDenied
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
self.form = self.get_form() self.form = self.get_form()
self.start_date = self.form["start_date"] self.start_date = self.form["start_date"]
self.end_date = self.form["end_date"] self.end_date = self.form["end_date"]
res = super().post(request, *args, **kwargs) return super().post(request, *args, **kwargs)
if request.user.is_root or request.user.is_board_member:
return res
raise PermissionDenied
def get_initial(self): def get_initial(self):
init = { return {
"start_date": self.start_date.strftime("%Y-%m-%d %H:%M:%S"), "start_date": self.start_date.strftime("%Y-%m-%d %H:%M:%S"),
"end_date": self.end_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): def get_context_data(self, **kwargs):
from subscription.models import Subscription
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
kwargs["subscriptions_total"] = Subscription.objects.filter( kwargs["subscriptions_total"] = Subscription.objects.filter(
subscription_end__gte=self.end_date, subscription_start__lte=self.start_date 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["payment_types"] = settings.SITH_COUNTER_PAYMENT_METHOD
kwargs["locations"] = settings.SITH_SUBSCRIPTION_LOCATIONS kwargs["locations"] = settings.SITH_SUBSCRIPTION_LOCATIONS
return kwargs return kwargs
def get_success_url(self, **kwargs):
return reverse_lazy("subscriptions:stats")

View File

@ -85,6 +85,7 @@ export default defineConfig((config: UserConfig) => {
inject({ inject({
// biome-ignore lint/style/useNamingConvention: that's how it's called // biome-ignore lint/style/useNamingConvention: that's how it's called
Alpine: "alpinejs", Alpine: "alpinejs",
htmx: "htmx.org",
}), }),
viteStaticCopy({ viteStaticCopy({
targets: [ targets: [