From edd8b9a3856684ae3f0420bf51de8917607ba8f0 Mon Sep 17 00:00:00 2001 From: imperosol Date: Mon, 30 Jun 2025 15:57:53 +0200 Subject: [PATCH] test: ReservationForm --- reservation/api.py | 3 +- reservation/forms.py | 7 ++ reservation/migrations/0001_initial.py | 1 + reservation/models.py | 11 +- reservation/schemas.py | 4 +- .../components/room-scheduler-index.ts | 2 +- reservation/tests/test_room_api.py | 30 +++-- reservation/tests/test_slot.py | 111 ++++++++++++++++++ reservation/tests/test_slot_api.py | 39 ------ 9 files changed, 152 insertions(+), 56 deletions(-) create mode 100644 reservation/tests/test_slot.py delete mode 100644 reservation/tests/test_slot_api.py diff --git a/reservation/api.py b/reservation/api.py index 1a19f962..06e5cae5 100644 --- a/reservation/api.py +++ b/reservation/api.py @@ -23,7 +23,7 @@ class ReservableRoomController(ControllerBase): @route.get( "", response=list[RoomSchema], - permissions=[HasPerm("reservation.viem_room")], + permissions=[HasPerm("reservation.view_room")], url_name="fetch_reservable_rooms", ) def fetch_rooms(self, filters: Query[RoomFilterSchema]): @@ -36,6 +36,7 @@ class ReservationSlotController(ControllerBase): "", response=PaginatedResponseSchema[SlotSchema], permissions=[HasPerm("reservation.view_reservationslot")], + url_name="fetch_reservation_slots", ) @paginate(PageNumberPaginationExtra) def fetch_slots(self, filters: Query[SlotFilterSchema]): diff --git a/reservation/forms.py b/reservation/forms.py index 6b8347c2..90b0d891 100644 --- a/reservation/forms.py +++ b/reservation/forms.py @@ -1,4 +1,6 @@ from django import forms +from django.core.exceptions import NON_FIELD_ERRORS +from django.utils.translation import gettext_lazy as _ from club.widgets.ajax_select import AutoCompleteSelectClub from core.models import User @@ -43,6 +45,11 @@ class ReservationForm(forms.ModelForm): fields = ["room", "start_at", "end_at", "comment"] field_classes = {"start_at": FutureDateTimeField, "end_at": FutureDateTimeField} widgets = {"start_at": SelectDateTime(), "end_at": SelectDateTime()} + error_messages = { + NON_FIELD_ERRORS: { + "start_after_end": _("The start must be set before the end") + } + } def __init__(self, *args, author: User, **kwargs): super().__init__(*args, **kwargs) diff --git a/reservation/migrations/0001_initial.py b/reservation/migrations/0001_initial.py index 030b544c..7ecac7fc 100644 --- a/reservation/migrations/0001_initial.py +++ b/reservation/migrations/0001_initial.py @@ -109,6 +109,7 @@ class Migration(migrations.Migration): models.CheckConstraint( condition=models.Q(("end_at__gt", models.F("start_at"))), name="reservation_slot_end_after_start", + violation_error_code="start_after_end", ) ], }, diff --git a/reservation/models.py b/reservation/models.py index 34d87e3f..1e340821 100644 --- a/reservation/models.py +++ b/reservation/models.py @@ -51,10 +51,10 @@ class Room(models.Model): class ReservationSlotQuerySet(models.QuerySet): - def overlapping_with(self, other: ReservationSlot) -> Self: + def overlapping_with(self, slot: ReservationSlot) -> Self: return self.filter( - Q(start_at__lt=other.start_at, start_at__gt=other.start_at) - | Q(end_at__gt=other.start_at, end_at__lt=other.end_at) + Q(start_at__lt=slot.start_at, end_at__gt=slot.start_at) + | Q(start_at__lt=slot.end_at, end_at__gt=slot.end_at) ) @@ -68,7 +68,7 @@ class ReservationSlot(models.Model): author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_("author")) comment = models.TextField(_("comment"), blank=True, default="") start_at = models.DateTimeField(_("slot start"), db_index=True) - end_at = models.DateTimeField(verbose_name=_("slot end")) + end_at = models.DateTimeField(_("slot end")) created_at = models.DateTimeField(auto_now_add=True) objects = ReservationSlotQuerySet.as_manager() @@ -78,8 +78,9 @@ class ReservationSlot(models.Model): verbose_name_plural = _("reservation slots") constraints = [ models.CheckConstraint( - check=Q(end_at__gt=F("start_at")), + condition=Q(end_at__gt=F("start_at")), name="reservation_slot_end_after_start", + violation_error_code="start_after_end", ) ] diff --git a/reservation/schemas.py b/reservation/schemas.py index 154e8a1b..434da8cb 100644 --- a/reservation/schemas.py +++ b/reservation/schemas.py @@ -3,7 +3,7 @@ from datetime import datetime from ninja import FilterSchema, ModelSchema from pydantic import Field -from club.schemas import ClubSchema +from club.schemas import SimpleClubSchema from core.schemas import SimpleUserSchema from reservation.models import ReservationSlot, Room @@ -17,7 +17,7 @@ class RoomSchema(ModelSchema): model = Room fields = ["id", "name", "description", "location"] - club: ClubSchema + club: SimpleClubSchema @staticmethod def resolve_location(obj: Room): diff --git a/reservation/static/bundled/reservation/components/room-scheduler-index.ts b/reservation/static/bundled/reservation/components/room-scheduler-index.ts index ab0638d3..0dcf3e80 100644 --- a/reservation/static/bundled/reservation/components/room-scheduler-index.ts +++ b/reservation/static/bundled/reservation/components/room-scheduler-index.ts @@ -17,9 +17,9 @@ import { } from "#openapi"; import { paginated } from "#core:utils/api"; +import type { SlotSelectedEventArg } from "#reservation:reservation/types"; import interactionPlugin from "@fullcalendar/interaction"; import resourceTimelinePlugin from "@fullcalendar/resource-timeline"; -import type { SlotSelectedEventArg } from "#reservation:reservation/types"; @registerComponent("room-scheduler") export class RoomScheduler extends inheritHtmlElement("div") { diff --git a/reservation/tests/test_room_api.py b/reservation/tests/test_room_api.py index 4465bb14..e2a8fa32 100644 --- a/reservation/tests/test_room_api.py +++ b/reservation/tests/test_room_api.py @@ -1,28 +1,42 @@ import pytest +from django.contrib.auth.models import Permission +from django.test import Client +from django.urls import reverse from model_bakery import baker -from ninja_extra.testing import TestClient from pytest_django.asserts import assertNumQueries -from reservation.api import ReservableRoomController +from core.models import User from reservation.models import Room @pytest.mark.django_db class TestFetchRoom: - def test_fetch_simple(self): + @pytest.fixture + def user(self): + return baker.make( + User, + user_permissions=[Permission.objects.get(codename="view_room")], + ) + + def test_fetch_simple(self, client: Client, user: User): rooms = baker.make(Room, _quantity=3, _bulk_create=True) - response = TestClient(ReservableRoomController).get("") + client.force_login(user) + response = client.get(reverse("api:fetch_reservable_rooms")) + assert response.status_code == 200 assert response.json() == [ { "id": room.id, "name": room.name, "description": room.description, - "address": room.address, + "location": room.location, "club": {"id": room.club.id, "name": room.club.name}, } for room in rooms ] - def test_nb_queries(self): - with assertNumQueries(1): - TestClient(ReservableRoomController).get("") + def test_nb_queries(self, client: Client, user: User): + client.force_login(user) + with assertNumQueries(5): + # 4 for authentication + # 1 to fetch the actual data + client.get(reverse("api:fetch_reservable_rooms")) diff --git a/reservation/tests/test_slot.py b/reservation/tests/test_slot.py new file mode 100644 index 00000000..9131e14a --- /dev/null +++ b/reservation/tests/test_slot.py @@ -0,0 +1,111 @@ +from datetime import timedelta + +import pytest +from django.contrib.auth.models import Permission +from django.test import Client +from django.urls import reverse +from django.utils.timezone import now +from model_bakery import baker +from pytest_django.asserts import assertNumQueries + +from core.models import User +from reservation.forms import ReservationForm +from reservation.models import ReservationSlot, Room + + +@pytest.mark.django_db +class TestFetchReservationSlotsApi: + @pytest.fixture + def user(self): + return baker.make( + User, + user_permissions=[Permission.objects.get(codename="view_reservationslot")], + ) + + def test_fetch_simple(self, client: Client, user: User): + slots = baker.make(ReservationSlot, _quantity=5, _bulk_create=True) + client.force_login(user) + response = client.get(reverse("api:fetch_reservation_slots")) + assert response.json()["results"] == [ + { + "id": slot.id, + "room": slot.room_id, + "comment": slot.comment, + "start": slot.start_at.isoformat(timespec="milliseconds").replace( + "+00:00", "Z" + ), + "end": slot.end_at.isoformat(timespec="milliseconds").replace( + "+00:00", "Z" + ), + "author": { + "id": slot.author.id, + "first_name": slot.author.first_name, + "last_name": slot.author.last_name, + "nick_name": slot.author.nick_name, + }, + } + for slot in slots + ] + + def test_nb_queries(self, client: Client, user: User): + client.force_login(user) + with assertNumQueries(5): + # 4 for authentication + # 1 to fetch the actual data + client.get(reverse("api:fetch_reservation_slots")) + + +@pytest.mark.django_db +class TestReservationForm: + def test_ok(self): + start = now() + timedelta(hours=2) + end = start + timedelta(hours=1) + form = ReservationForm( + author=baker.make(User), + data={"room": baker.make(Room), "start_at": start, "end_at": end}, + ) + assert form.is_valid() + + @pytest.mark.parametrize( + ("start_date", "end_date", "errors"), + [ + ( + now() - timedelta(hours=2), + now() + timedelta(hours=2), + {"start_at": ["Assurez-vous que cet horodatage est dans le futur"]}, + ), + ( + now() + timedelta(hours=3), + now() + timedelta(hours=2), + {"__all__": ["Le début doit être placé avant la fin"]}, + ), + ], + ) + def test_invalid_timedates(self, start_date, end_date, errors): + form = ReservationForm( + author=baker.make(User), + data={"room": baker.make(Room), "start_at": start_date, "end_at": end_date}, + ) + assert not form.is_valid() + assert form.errors == errors + + def test_unavailable_room(self): + room = baker.make(Room) + baker.make( + ReservationSlot, + room=room, + start_at=now() + timedelta(hours=2), + end_at=now() + timedelta(hours=4), + ) + form = ReservationForm( + author=baker.make(User), + data={ + "room": room, + "start_at": now() + timedelta(hours=1), + "end_at": now() + timedelta(hours=3), + }, + ) + assert not form.is_valid() + assert form.errors == { + "__all__": ["Il y a déjà une réservation sur ce créneau."] + } diff --git a/reservation/tests/test_slot_api.py b/reservation/tests/test_slot_api.py deleted file mode 100644 index cfbff898..00000000 --- a/reservation/tests/test_slot_api.py +++ /dev/null @@ -1,39 +0,0 @@ -import pytest -from model_bakery import baker -from ninja_extra.testing import TestClient -from pytest_django.asserts import assertNumQueries - -from reservation.api import ReservableRoomController, ReservationSlotController -from reservation.models import ReservationSlot - - -@pytest.mark.django_db -class TestFetchRoom: - def test_fetch_simple(self): - slots = baker.make(ReservationSlot, _quantity=5, _bulk_create=True) - response = TestClient(ReservationSlotController).get("") - assert response.json() == [ - { - "id": slot.id, - "room": slot.room_id, - "comment": slot.comment, - "nb_people": slot.nb_people, - "start": slot.start_at.isoformat(timespec="milliseconds").replace( - "+00:00", "Z" - ), - "end": slot.end_at.isoformat(timespec="milliseconds").replace( - "+00:00", "Z" - ), - "author": { - "id": slot.author.id, - "first_name": slot.author.first_name, - "last_name": slot.author.last_name, - "nick_name": slot.author.nick_name, - }, - } - for slot in slots - ] - - def test_nb_queries(self): - with assertNumQueries(1): - TestClient(ReservableRoomController).get("")