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() %}
{% endif %}
@@ -74,8 +79,11 @@
{% if user.profile_pict %}
{% else %}
-
+
{% 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 %}
-
-
-{% 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: [