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/core/forms.scss b/core/static/core/forms.scss new file mode 100644 index 00000000..7dab0484 --- /dev/null +++ b/core/static/core/forms.scss @@ -0,0 +1,89 @@ +@import "colors"; + +/** + * Style related to forms + */ + +a.button, +button, +input[type="button"], +input[type="submit"], +input[type="reset"], +input[type="file"] { + border: none; + text-decoration: none; + background-color: $background-button-color; + padding: 0.4em; + margin: 0.1em; + font-size: 1.2em; + border-radius: 5px; + color: black; + + &:hover { + background: hsl(0, 0%, 83%); + } +} + +a.button, +input[type="button"], +input[type="submit"], +input[type="reset"], +input[type="file"] { + font-weight: bold; +} + +a.button:not(:disabled), +button:not(:disabled), +input[type="button"]:not(:disabled), +input[type="submit"]:not(:disabled), +input[type="reset"]:not(:disabled), +input[type="checkbox"]:not(:disabled), +input[type="file"]:not(:disabled) { + cursor: pointer; +} + +input, +textarea[type="text"], +[type="number"] { + border: none; + text-decoration: none; + background-color: $background-button-color; + padding: 0.4em; + margin: 0.1em; + font-size: 1.2em; + border-radius: 5px; + max-width: 95%; +} + +textarea { + border: none; + text-decoration: none; + background-color: $background-button-color; + padding: 7px; + font-size: 1.2em; + border-radius: 5px; + font-family: sans-serif; +} + +select { + border: none; + text-decoration: none; + font-size: 1.2em; + background-color: $background-button-color; + padding: 10px; + border-radius: 5px; + cursor: pointer; +} + +a:not(.button) { + text-decoration: none; + color: $primary-dark-color; + + &:hover { + color: $primary-light-color; + } + + &:active { + color: $primary-color; + } +} diff --git a/core/static/core/style.scss b/core/static/core/style.scss index 21481454..50892df3 100644 --- a/core/static/core/style.scss +++ b/core/static/core/style.scss @@ -1,4 +1,5 @@ @import "colors"; +@import "forms"; /*--------------------------MEDIA QUERY HELPERS------------------------*/ $small-devices: 576px; @@ -13,91 +14,6 @@ body { font-family: sans-serif; } - -a.button, -button, -input[type="button"], -input[type="submit"], -input[type="reset"], -input[type="file"] { - border: none; - text-decoration: none; - background-color: $background-button-color; - padding: 0.4em; - margin: 0.1em; - font-size: 1.2em; - border-radius: 5px; - color: black; - - &:hover { - background: hsl(0, 0%, 83%); - } -} - -a.button, -input[type="button"], -input[type="submit"], -input[type="reset"], -input[type="file"] { - font-weight: bold; -} - -a.button:not(:disabled), -button:not(:disabled), -input[type="button"]:not(:disabled), -input[type="submit"]:not(:disabled), -input[type="reset"]:not(:disabled), -input[type="checkbox"]:not(:disabled), -input[type="file"]:not(:disabled) { - cursor: pointer; -} - -input, -textarea[type="text"], -[type="number"] { - border: none; - text-decoration: none; - background-color: $background-button-color; - padding: 0.4em; - margin: 0.1em; - font-size: 1.2em; - border-radius: 5px; - max-width: 95%; -} - -textarea { - border: none; - text-decoration: none; - background-color: $background-button-color; - padding: 7px; - font-size: 1.2em; - border-radius: 5px; - font-family: sans-serif; -} - -select { - border: none; - text-decoration: none; - font-size: 1.2em; - background-color: $background-button-color; - padding: 10px; - border-radius: 5px; - cursor: pointer; -} - -a:not(.button) { - text-decoration: none; - color: $primary-dark-color; - - &:hover { - color: $primary-light-color; - } - - &:active { - color: $primary-color; - } -} - [aria-busy] { --loading-size: 50px; --loading-stroke: 5px; @@ -262,8 +178,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 +285,49 @@ a:not(.button) { .alert-aside { display: flex; flex-direction: column; + gap: 5px; + } + } + + .tabs { + border-radius: 5px; + + .tab-headers { + display: flex; + flex-flow: row wrap; + background-color: $primary-neutral-light-color; + padding: 3px 12px 12px; + column-gap: 20px; + border-top-left-radius: 5px; + border-top-right-radius: 5px; + + .tab-header { + border: none; + padding-right: 0; + padding-left: 0; + font-size: 120%; + background-color: unset; + position: relative; + &:after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + border-bottom: 4px solid darken($primary-neutral-light-color, 10%); + border-radius: 2px; + transition: all 0.2s ease-in-out; + } + &:hover:after { + border-bottom-color: darken($primary-neutral-light-color, 20%); + } + &.active:after { + border-bottom-color: $primary-dark-color; + } + } + } + section { + padding: 20px; } } @@ -1246,26 +1207,26 @@ u, /*-----------------------------USER PROFILE----------------------------*/ .user_mini_profile { - height: 100%; - width: 100%; + --gap-size: 1em; + max-height: 100%; + max-width: 100%; + display: flex; + flex-direction: column; + gap: var(--gap-size); img { - max-width: 100%; max-height: 100%; + max-width: 100%; } .user_mini_profile_infos { padding: 0.2em; - height: 20%; + max-height: 20%; display: flex; flex-wrap: nowrap; justify-content: space-around; font-size: 0.9em; - div { - max-height: 100%; - } - .user_mini_profile_infos_text { text-align: center; @@ -1276,10 +1237,10 @@ u, } .user_mini_profile_picture { - height: 80%; - display: flex; - justify-content: center; - align-items: center; + max-height: calc(80% - var(--gap-size)); + max-width: 100%; + display: block; + margin: auto; } } diff --git a/core/templates/core/macros.jinja b/core/templates/core/macros.jinja index 43a90d07..6ab52cad 100644 --- a/core/templates/core/macros.jinja +++ b/core/templates/core/macros.jinja @@ -66,7 +66,12 @@ {% if user.promo and user.promo_has_logo() %}
- Promo {{ user.promo }} + Promo {{ user.promo }}
{% endif %} @@ -74,8 +79,11 @@ {% if user.profile_pict %} {% trans %}Profile{% endtrans %} {% else %} - {% trans %}Profile{% endtrans %} + {% trans %}Profile{% endtrans %} {% endif %} @@ -170,12 +178,12 @@ {% endmacro %} {% macro paginate_htmx(current_page, paginator) %} - {# Add pagination buttons for pages without Alpine but supporting framgents. + {# Add pagination buttons for pages without Alpine but supporting fragments. This must be coupled with a view that handles pagination - with the Django Paginator object and supports framgents. + with the Django Paginator object and supports fragments. - The relpaced fragment will be #content so make sure you are calling this macro inside your content block. + The replaced fragment will be #content so make sure you are calling this macro inside your content block. Parameters: current_page (django.core.paginator.Page): the current page object @@ -247,9 +255,9 @@ {% macro select_all_checkbox(form_id) %} + +{% 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() }} - +
+ {% with title1=_("Existing member"), title2=_("New member") %} + {{ tabs([ + (title1, form_fragment(existing_user_form, existing_user_post_url)), + (title2, form_fragment(new_user_form, new_user_post_url)), + ]) }} + {% endwith %} +
{% endblock %} diff --git a/subscription/tests/__init__.py b/subscription/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/subscription/tests.py b/subscription/tests/test_dates.py similarity index 99% rename from subscription/tests.py rename to subscription/tests/test_dates.py index d853c55a..181246b1 100644 --- a/subscription/tests.py +++ b/subscription/tests/test_dates.py @@ -12,6 +12,8 @@ # OR WITHIN THE LOCAL FILE "LICENSE" # # +"""Tests focused on the computing of subscription end, start and duration""" + from datetime import date import freezegun diff --git a/subscription/tests/test_new_susbcription.py b/subscription/tests/test_new_susbcription.py new file mode 100644 index 00000000..8ea51d68 --- /dev/null +++ b/subscription/tests/test_new_susbcription.py @@ -0,0 +1,151 @@ +"""Tests focused on testing subscription creation""" + +from datetime import timedelta +from typing import Callable + +import pytest +from dateutil.relativedelta import relativedelta +from django.test import Client +from django.urls import reverse +from django.utils.timezone import localdate +from model_bakery import baker +from pytest_django.asserts import assertRedirects +from pytest_django.fixtures import SettingsWrapper + +from core.baker_recipes import board_user, old_subscriber_user, subscriber_user +from core.models import User +from subscription.forms import SubscriptionExistingUserForm, SubscriptionNewUserForm + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "user_factory", + [old_subscriber_user.make, lambda: baker.make(User)], +) +def test_form_existing_user_valid( + user_factory: Callable[[], User], settings: SettingsWrapper +): + """Test `SubscriptionExistingUserForm`""" + user = user_factory() + data = { + "member": user, + "subscription_type": "deux-semestres", + "location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0], + "payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0], + } + form = SubscriptionExistingUserForm(data) + + assert form.is_valid() + form.save() + user.refresh_from_db() + assert user.is_subscribed + + +@pytest.mark.django_db +def test_form_existing_user_invalid(settings: SettingsWrapper): + """Test `SubscriptionExistingUserForm`, with users that shouldn't subscribe.""" + user = subscriber_user.make() + # make sure the current subscription will end in a long time + last_sub = user.subscriptions.order_by("subscription_end").last() + last_sub.subscription_end = localdate() + timedelta(weeks=50) + last_sub.save() + data = { + "member": user, + "subscription_type": "deux-semestres", + "location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0], + "payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0], + } + form = SubscriptionExistingUserForm(data) + + assert not form.is_valid() + with pytest.raises(ValueError): + form.save() + + +@pytest.mark.django_db +def test_form_new_user(settings: SettingsWrapper): + data = { + "first_name": "John", + "last_name": "Doe", + "email": "jdoe@utbm.fr", + "date_of_birth": localdate() - relativedelta(years=18), + "subscription_type": "deux-semestres", + "location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0], + "payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0], + } + form = SubscriptionNewUserForm(data) + assert form.is_valid() + form.save() + user = User.objects.get(email="jdoe@utbm.fr") + assert user.username == "jdoe" + assert user.is_subscribed + + # if trying to instantiate a new form with the same email, + # it should fail + form = SubscriptionNewUserForm(data) + assert not form.is_valid() + with pytest.raises(ValueError): + form.save() + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "user_factory", [lambda: baker.make(User, is_superuser=True), board_user.make] +) +def test_load_page(client: Client, user_factory: Callable[[], User]): + """Just check the page doesn't crash.""" + client.force_login(user_factory()) + res = client.get(reverse("subscription:subscription")) + assert res.status_code == 200 + + +@pytest.mark.django_db +def test_submit_form_existing_user(client: Client, settings: SettingsWrapper): + client.force_login(board_user.make()) + user = old_subscriber_user.make() + response = client.post( + reverse("subscription:fragment-existing-user"), + { + "member": user.id, + "subscription_type": "deux-semestres", + "location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0], + "payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0], + }, + ) + user.refresh_from_db() + assert user.is_subscribed + current_subscription = user.subscriptions.order_by("-subscription_start").first() + assertRedirects( + response, + reverse( + "subscription:creation-success", + kwargs={"subscription_id": current_subscription.id}, + ), + ) + + +@pytest.mark.django_db +def test_submit_form_new_user(client: Client, settings: SettingsWrapper): + client.force_login(board_user.make()) + response = client.post( + reverse("subscription:fragment-new-user"), + { + "first_name": "John", + "last_name": "Doe", + "email": "jdoe@utbm.fr", + "date_of_birth": localdate() - relativedelta(years=18), + "subscription_type": "deux-semestres", + "location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0], + "payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0], + }, + ) + user = User.objects.get(email="jdoe@utbm.fr") + assert user.is_subscribed + current_subscription = user.subscriptions.order_by("-subscription_start").first() + assertRedirects( + response, + reverse( + "subscription:creation-success", + kwargs={"subscription_id": current_subscription.id}, + ), + ) 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 9a39e7b0..2948391b 100644 --- a/subscription/views.py +++ b/subscription/views.py @@ -13,166 +13,96 @@ # # -import secrets - -from django import forms from django.conf import settings -from django.core.exceptions import PermissionDenied, ValidationError -from django.urls import reverse_lazy -from django.utils.translation import gettext_lazy as _ -from django.views.generic.edit import CreateView, FormView +from django.contrib.auth.mixins import UserPassesTestMixin +from django.core.exceptions import PermissionDenied +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 core.models import User -from core.views.forms import SelectDate, SelectDateTime -from core.views.widgets.select import AutoCompleteSelectUser +from subscription.forms import ( + SelectionDateForm, + SubscriptionExistingUserForm, + SubscriptionNewUserForm, +) from subscription.models import Subscription -class SelectionDateForm(forms.Form): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["start_date"] = forms.DateTimeField( - label=_("Start date"), widget=SelectDateTime, required=True - ) - self.fields["end_date"] = forms.DateTimeField( - label=_("End date"), widget=SelectDateTime, required=True - ) +class CanCreateSubscriptionMixin(UserPassesTestMixin): + def test_func(self): + return self.request.user.can_create_subscription -class SubscriptionForm(forms.ModelForm): - 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(secrets.token_urlsafe(nbytes=10)) - 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 - - -class NewSubscription(CreateView): +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( + initial={"member": self.request.GET.get("member")} + ), + "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 @@ -181,6 +111,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: [