test: ReservationForm

This commit is contained in:
imperosol
2025-06-30 15:57:53 +02:00
parent a322a0895a
commit edd8b9a385
9 changed files with 152 additions and 56 deletions

View File

@ -23,7 +23,7 @@ class ReservableRoomController(ControllerBase):
@route.get( @route.get(
"", "",
response=list[RoomSchema], response=list[RoomSchema],
permissions=[HasPerm("reservation.viem_room")], permissions=[HasPerm("reservation.view_room")],
url_name="fetch_reservable_rooms", url_name="fetch_reservable_rooms",
) )
def fetch_rooms(self, filters: Query[RoomFilterSchema]): def fetch_rooms(self, filters: Query[RoomFilterSchema]):
@ -36,6 +36,7 @@ class ReservationSlotController(ControllerBase):
"", "",
response=PaginatedResponseSchema[SlotSchema], response=PaginatedResponseSchema[SlotSchema],
permissions=[HasPerm("reservation.view_reservationslot")], permissions=[HasPerm("reservation.view_reservationslot")],
url_name="fetch_reservation_slots",
) )
@paginate(PageNumberPaginationExtra) @paginate(PageNumberPaginationExtra)
def fetch_slots(self, filters: Query[SlotFilterSchema]): def fetch_slots(self, filters: Query[SlotFilterSchema]):

View File

@ -1,4 +1,6 @@
from django import forms 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 club.widgets.ajax_select import AutoCompleteSelectClub
from core.models import User from core.models import User
@ -43,6 +45,11 @@ class ReservationForm(forms.ModelForm):
fields = ["room", "start_at", "end_at", "comment"] fields = ["room", "start_at", "end_at", "comment"]
field_classes = {"start_at": FutureDateTimeField, "end_at": FutureDateTimeField} field_classes = {"start_at": FutureDateTimeField, "end_at": FutureDateTimeField}
widgets = {"start_at": SelectDateTime(), "end_at": SelectDateTime()} 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): def __init__(self, *args, author: User, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)

View File

@ -109,6 +109,7 @@ class Migration(migrations.Migration):
models.CheckConstraint( models.CheckConstraint(
condition=models.Q(("end_at__gt", models.F("start_at"))), condition=models.Q(("end_at__gt", models.F("start_at"))),
name="reservation_slot_end_after_start", name="reservation_slot_end_after_start",
violation_error_code="start_after_end",
) )
], ],
}, },

View File

@ -51,10 +51,10 @@ class Room(models.Model):
class ReservationSlotQuerySet(models.QuerySet): class ReservationSlotQuerySet(models.QuerySet):
def overlapping_with(self, other: ReservationSlot) -> Self: def overlapping_with(self, slot: ReservationSlot) -> Self:
return self.filter( return self.filter(
Q(start_at__lt=other.start_at, start_at__gt=other.start_at) Q(start_at__lt=slot.start_at, end_at__gt=slot.start_at)
| Q(end_at__gt=other.start_at, end_at__lt=other.end_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")) author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_("author"))
comment = models.TextField(_("comment"), blank=True, default="") comment = models.TextField(_("comment"), blank=True, default="")
start_at = models.DateTimeField(_("slot start"), db_index=True) 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) created_at = models.DateTimeField(auto_now_add=True)
objects = ReservationSlotQuerySet.as_manager() objects = ReservationSlotQuerySet.as_manager()
@ -78,8 +78,9 @@ class ReservationSlot(models.Model):
verbose_name_plural = _("reservation slots") verbose_name_plural = _("reservation slots")
constraints = [ constraints = [
models.CheckConstraint( models.CheckConstraint(
check=Q(end_at__gt=F("start_at")), condition=Q(end_at__gt=F("start_at")),
name="reservation_slot_end_after_start", name="reservation_slot_end_after_start",
violation_error_code="start_after_end",
) )
] ]

View File

@ -3,7 +3,7 @@ from datetime import datetime
from ninja import FilterSchema, ModelSchema from ninja import FilterSchema, ModelSchema
from pydantic import Field from pydantic import Field
from club.schemas import ClubSchema from club.schemas import SimpleClubSchema
from core.schemas import SimpleUserSchema from core.schemas import SimpleUserSchema
from reservation.models import ReservationSlot, Room from reservation.models import ReservationSlot, Room
@ -17,7 +17,7 @@ class RoomSchema(ModelSchema):
model = Room model = Room
fields = ["id", "name", "description", "location"] fields = ["id", "name", "description", "location"]
club: ClubSchema club: SimpleClubSchema
@staticmethod @staticmethod
def resolve_location(obj: Room): def resolve_location(obj: Room):

View File

@ -17,9 +17,9 @@ import {
} from "#openapi"; } from "#openapi";
import { paginated } from "#core:utils/api"; import { paginated } from "#core:utils/api";
import type { SlotSelectedEventArg } from "#reservation:reservation/types";
import interactionPlugin from "@fullcalendar/interaction"; import interactionPlugin from "@fullcalendar/interaction";
import resourceTimelinePlugin from "@fullcalendar/resource-timeline"; import resourceTimelinePlugin from "@fullcalendar/resource-timeline";
import type { SlotSelectedEventArg } from "#reservation:reservation/types";
@registerComponent("room-scheduler") @registerComponent("room-scheduler")
export class RoomScheduler extends inheritHtmlElement("div") { export class RoomScheduler extends inheritHtmlElement("div") {

View File

@ -1,28 +1,42 @@
import pytest 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 model_bakery import baker
from ninja_extra.testing import TestClient
from pytest_django.asserts import assertNumQueries from pytest_django.asserts import assertNumQueries
from reservation.api import ReservableRoomController from core.models import User
from reservation.models import Room from reservation.models import Room
@pytest.mark.django_db @pytest.mark.django_db
class TestFetchRoom: 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) 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() == [ assert response.json() == [
{ {
"id": room.id, "id": room.id,
"name": room.name, "name": room.name,
"description": room.description, "description": room.description,
"address": room.address, "location": room.location,
"club": {"id": room.club.id, "name": room.club.name}, "club": {"id": room.club.id, "name": room.club.name},
} }
for room in rooms for room in rooms
] ]
def test_nb_queries(self): def test_nb_queries(self, client: Client, user: User):
with assertNumQueries(1): client.force_login(user)
TestClient(ReservableRoomController).get("") with assertNumQueries(5):
# 4 for authentication
# 1 to fetch the actual data
client.get(reverse("api:fetch_reservable_rooms"))

View File

@ -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."]
}

View File

@ -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("")