diff --git a/core/api.py b/core/api.py index 06b32989..af4daff5 100644 --- a/core/api.py +++ b/core/api.py @@ -25,6 +25,7 @@ from core.schemas import ( UserFamilySchema, UserFilterSchema, UserProfileSchema, + UserSchema, ) from core.templatetags.renderer import markdown @@ -69,16 +70,22 @@ class MailingListController(ControllerBase): return data -@api_controller("/user", permissions=[CanAccessLookup]) +@api_controller("/user") class UserController(ControllerBase): - @route.get("", response=list[UserProfileSchema]) + @route.get("", response=list[UserProfileSchema], permissions=[CanAccessLookup]) def fetch_profiles(self, pks: Query[set[int]]): return User.objects.filter(pk__in=pks) + @route.get("/{int:user_id}", response=UserSchema, permissions=[CanView]) + def fetch_user(self, user_id: int): + """Fetch a single user""" + return self.get_object_or_exception(User, id=user_id) + @route.get( "/search", response=PaginatedResponseSchema[UserProfileSchema], url_name="search_users", + permissions=[CanAccessLookup], ) @paginate(PageNumberPaginationExtra, page_size=20) def search_users(self, filters: Query[UserFilterSchema]): diff --git a/core/schemas.py b/core/schemas.py index b5f5991f..5231d859 100644 --- a/core/schemas.py +++ b/core/schemas.py @@ -34,6 +34,22 @@ class SimpleUserSchema(ModelSchema): fields = ["id", "nick_name", "first_name", "last_name"] +class UserSchema(ModelSchema): + class Meta: + model = User + fields = [ + "id", + "nick_name", + "first_name", + "last_name", + "date_of_birth", + "email", + "role", + "quote", + "promo", + ] + + class UserProfileSchema(ModelSchema): """The necessary information to show a user profile""" diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index a638339e..dcf19d5e 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-09-01 18:18+0200\n" +"POT-Creation-Date: 2025-09-02 15:56+0200\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal \n" @@ -5135,6 +5135,10 @@ msgstr "Tee-shirt AE" msgid "A user with that email address already exists" msgstr "Un utilisateur avec cette adresse email existe déjà" +#: subscription/forms.py +msgid "This user didn't fill its birthdate yet." +msgstr "Cet utilisateur n'a pas encore renseigné sa date de naissance" + #: subscription/models.py msgid "Bad subscription type" msgstr "Mauvais type de cotisation" @@ -5174,7 +5178,7 @@ msgid "" "%(user)s received its new %(type)s subscription. It will be active until " "%(end)s included." msgstr "" -"%(user)s a reçu sa nouvelle cotisaton %(type)s. Elle sert active jusqu'au " +"%(user)s a reçu sa nouvelle cotisaton %(type)s. Elle sera active jusqu'au " "%(end)s inclu." #: subscription/templates/subscription/fragments/creation_success.jinja diff --git a/subscription/forms.py b/subscription/forms.py index babc613c..41d5ddbc 100644 --- a/subscription/forms.py +++ b/subscription/forms.py @@ -23,8 +23,8 @@ class SelectionDateForm(forms.Form): class SubscriptionForm(forms.ModelForm): - def __init__(self, *args, **kwargs): - initial = kwargs.pop("initial", {}) + def __init__(self, *args, initial=None, **kwargs): + initial = initial or {} if "subscription_type" not in initial: initial["subscription_type"] = "deux-semestres" if "payment_method" not in initial: @@ -131,8 +131,55 @@ class SubscriptionExistingUserForm(SubscriptionForm): """Form to add a subscription to an existing user.""" template_name = "subscription/forms/create_existing_user.html" + required_css_class = "required" + + birthdate = forms.fields_for_model( + User, + ["date_of_birth"], + widgets={"date_of_birth": SelectDate(attrs={"hidden": True})}, + help_texts={"date_of_birth": _("This user didn't fill its birthdate yet.")}, + )["date_of_birth"] class Meta: model = Subscription fields = ["member", "subscription_type", "payment_method", "location"] widgets = {"member": AutoCompleteSelectUser} + + field_order = [ + "member", + "birthdate", + "subscription_type", + "payment_method", + "location", + ] + + def __init__(self, *args, initial=None, **kwargs): + super().__init__(*args, initial=initial, **kwargs) + self.fields["birthdate"].required = True + if not initial: + return + member = initial.get("member") + if member: + member = User.objects.filter(id=member).first() + if member and member.date_of_birth: + # if there is an initial member with a birthdate, + # there is no need to ask this to the user + self.fields["birthdate"].initial = member.date_of_birth + elif member: + # if there is an initial member without a birthdate, + # then the field must be displayed + self.fields["birthdate"].widget.attrs.update({"hidden": False}) + # if there is no initial member, it means that it will be + # dynamically selected using the AutoCompleteSelectUser widget. + # JS will take care of un-hiding the field if necessary + + def save(self, *args, **kwargs): + if self.errors: + return super().save(*args, **kwargs) + if ( + self.cleaned_data["birthdate"] is not None + and self.instance.member.date_of_birth != self.cleaned_data["birthdate"] + ): + self.instance.member.date_of_birth = self.cleaned_data["birthdate"] + self.instance.member.save() + return super().save(*args, **kwargs) diff --git a/subscription/static/bundled/subscription/creation-form-existing-user-index.ts b/subscription/static/bundled/subscription/creation-form-existing-user-index.ts index b997ad7b..3d258c90 100644 --- a/subscription/static/bundled/subscription/creation-form-existing-user-index.ts +++ b/subscription/static/bundled/subscription/creation-form-existing-user-index.ts @@ -1,3 +1,5 @@ +import { userFetchUser } from "#openapi"; + document.addEventListener("alpine:init", () => { Alpine.data("existing_user_subscription_form", () => ({ loading: false, @@ -12,13 +14,24 @@ document.addEventListener("alpine:init", () => { }, async loadProfile(userId: number) { + const birthdayInput = document.getElementById("id_birthdate") as HTMLInputElement; if (!Number.isInteger(userId)) { this.profileFragment = ""; + birthdayInput.hidden = true; return; } this.loading = true; - const response = await fetch(`/user/${userId}/mini/`); - this.profileFragment = await response.text(); + const [miniProfile, userInfos] = await Promise.all([ + fetch(`/user/${userId}/mini/`), + // biome-ignore lint/style/useNamingConvention: api is snake_case + userFetchUser({ path: { user_id: userId } }), + ]); + this.profileFragment = await miniProfile.text(); + // If the user has no birthdate yet, show the form input + // to fill this info. + // Else keep the input hidden and change its value to the user birthdate + birthdayInput.value = userInfos.data.date_of_birth; + birthdayInput.hidden = userInfos.data.date_of_birth !== null; this.loading = false; }, })); diff --git a/subscription/static/subscription/css/subscription.scss b/subscription/static/subscription/css/subscription.scss index d23d00b2..850abc76 100644 --- a/subscription/static/subscription/css/subscription.scss +++ b/subscription/static/subscription/css/subscription.scss @@ -23,6 +23,11 @@ * then display the user profile right in the middle of the remaining space. */ fieldset { flex: 0 1 auto; + + p:has(input[hidden]) { + // when the input is hidden, hide the whole label+input+help text group + display: none; + } } #subscription-form-user-mini-profile { diff --git a/subscription/tests/test_new_subscription.py b/subscription/tests/test_new_subscription.py index 1c4eaa6b..5933fe57 100644 --- a/subscription/tests/test_new_subscription.py +++ b/subscription/tests/test_new_subscription.py @@ -1,6 +1,6 @@ """Tests focused on testing subscription creation""" -from datetime import timedelta +from datetime import date, timedelta from typing import Callable import pytest @@ -31,6 +31,26 @@ def test_form_existing_user_valid( ): """Test `SubscriptionExistingUserForm`""" user = user_factory() + user.date_of_birth = date(year=1967, month=3, day=14) + user.save() + data = { + "member": user, + "birthdate": user.date_of_birth, + "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_with_birthdate(settings: SettingsWrapper): + """Test `SubscriptionExistingUserForm`""" + user = baker.make(User, date_of_birth=None) data = { "member": user, "subscription_type": "deux-semestres", @@ -38,11 +58,15 @@ def test_form_existing_user_valid( "payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0], } form = SubscriptionExistingUserForm(data) + assert not form.is_valid() + data |= {"birthdate": date(year=1967, month=3, day=14)} + form = SubscriptionExistingUserForm(data) assert form.is_valid() form.save() user.refresh_from_db() assert user.is_subscribed + assert user.date_of_birth == date(year=1967, month=3, day=14) @pytest.mark.django_db @@ -132,6 +156,14 @@ def test_page_access( assert res.status_code == status_code +@pytest.mark.django_db +def test_page_access_with_get_data(client: Client): + user = old_subscriber_user.make() + client.force_login(baker.make(User, is_superuser=True)) + res = client.get(reverse("subscription:subscription", query={"member": user.id})) + assert res.status_code == 200 + + @pytest.mark.django_db def test_submit_form_existing_user(client: Client, settings: SettingsWrapper): client.force_login( @@ -140,11 +172,12 @@ def test_submit_form_existing_user(client: Client, settings: SettingsWrapper): user_permissions=Permission.objects.filter(codename="add_subscription"), ) ) - user = old_subscriber_user.make() + user = old_subscriber_user.make(date_of_birth=date(year=1967, month=3, day=14)) response = client.post( reverse("subscription:fragment-existing-user"), { "member": user.id, + "birthdate": user.date_of_birth, "subscription_type": "deux-semestres", "location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0], "payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],