1
0
зеркало из https://github.com/ae-utbm/sith.git synced 2025-07-11 04:19:25 +00:00

test: room and slots creation/edition

Этот коммит содержится в:
imperosol
2025-06-30 22:36:17 +02:00
родитель 0baaf69714
Коммит 72bb4788f2
10 изменённых файлов: 249 добавлений и 74 удалений

Просмотреть файл

@ -1,4 +1,3 @@
from datetime import timedelta
from typing import Any, Literal
from django.core.exceptions import ValidationError
@ -6,7 +5,6 @@ from ninja import Query
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema
from pydantic import FutureDatetime
from api.permissions import HasPerm
from reservation.models import ReservationSlot, Room
@ -15,6 +13,7 @@ from reservation.schemas import (
RoomSchema,
SlotFilterSchema,
SlotSchema,
UpdateReservationSlotSchema,
)
@ -52,11 +51,12 @@ class ReservationSlotController(ControllerBase):
409: dict[Literal["detail"], dict[str, list[str]]],
422: dict[Literal["detail"], list[dict[str, Any]]],
},
url_name="change_reservation_slot",
)
def update_slot(self, start: FutureDatetime, duration: timedelta, slot_id: int):
def update_slot(self, slot_id: int, params: UpdateReservationSlotSchema):
slot = self.get_object_or_exception(ReservationSlot, id=slot_id)
slot.start_at = start
slot.end_at = start + duration
slot.start_at = params.start_at
slot.end_at = params.end_at
try:
slot.full_clean()
slot.save()

Просмотреть файл

@ -5,7 +5,6 @@ from typing import Self
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import F, Q
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from club.models import Club
@ -39,9 +38,6 @@ class Room(models.Model):
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse("reservation:room_detail", kwargs={"room_id": self.id})
def can_be_edited_by(self, user: User) -> bool:
# a user may edit a room if it has the global perm
# or is in the owner club board
@ -95,9 +91,10 @@ class ReservationSlot(models.Model):
# so in this case, don't do the overlap check and let
# Django manage the non-null constraint error.
return
if (
ReservationSlot.objects.overlapping_with(self)
.filter(room_id=self.room_id)
.exists()
):
overlapping = ReservationSlot.objects.overlapping_with(self).filter(
room_id=self.room_id
)
if self.id is not None:
overlapping = overlapping.exclude(id=self.id)
if overlapping.exists():
raise ValidationError(_("There is already a reservation on this slot."))

Просмотреть файл

@ -1,7 +1,7 @@
from datetime import datetime
from ninja import FilterSchema, ModelSchema
from pydantic import Field
from ninja import FilterSchema, ModelSchema, Schema
from pydantic import Field, FutureDatetime
from club.schemas import SimpleClubSchema
from core.schemas import SimpleUserSchema
@ -39,3 +39,8 @@ class SlotSchema(ModelSchema):
start: datetime = Field(alias="start_at")
end: datetime = Field(alias="end_at")
author: SimpleUserSchema
class UpdateReservationSlotSchema(Schema):
start_at: FutureDatetime
end_at: FutureDatetime

Просмотреть файл

@ -80,9 +80,9 @@ export class RoomScheduler extends inheritHtmlElement("div") {
const response = await reservationslotUpdateSlot({
// biome-ignore lint/style/useNamingConvention: api is snake_case
path: { slot_id: Number.parseInt(args.event.id) },
query: {
start: args.event.startStr,
duration: `PT${duration.getUTCHours()}H${duration.getUTCMinutes()}M${duration.getUTCSeconds()}S`,
body: {
start_at: args.event.startStr,
end_at: args.event.endStr,
},
});
if (response.response.ok) {

113
reservation/tests/test_room.py Обычный файл
Просмотреть файл

@ -0,0 +1,113 @@
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 pytest_django.asserts import assertNumQueries, assertRedirects
from club.models import Club
from core.models import User
from reservation.forms import RoomUpdateForm
from reservation.models import Room
@pytest.mark.django_db
class TestFetchRoom:
@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)
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,
"location": room.location,
"club": {"id": room.club.id, "name": room.club.name},
}
for room in rooms
]
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"))
@pytest.mark.django_db
class TestCreateRoom:
def test_ok(self, client: Client):
perm = Permission.objects.get(codename="add_room")
club = baker.make(Club)
client.force_login(
baker.make(User, user_permissions=[perm], groups=[club.board_group])
)
response = client.post(
reverse("reservation:room_create"),
data={"club": club.id, "name": "test", "location": "BELFORT"},
)
assertRedirects(response, reverse("club:tools", kwargs={"club_id": club.id}))
room = Room.objects.last()
assert room is not None
assert room.club == club
assert room.name == "test"
assert room.location == "BELFORT"
def test_permission_denied(self, client: Client):
club = baker.make(Club)
client.force_login(baker.make(User))
response = client.get(reverse("reservation:room_create"))
assert response.status_code == 403
response = client.post(
reverse("reservation:room_create"),
data={"club": club.id, "name": "test", "location": "BELFORT"},
)
assert response.status_code == 403
@pytest.mark.django_db
class TestUpdateRoom:
def test_ok(self, client: Client):
club = baker.make(Club)
room = baker.make(Room, club=club)
client.force_login(baker.make(User, groups=[club.board_group]))
url = reverse("reservation:room_edit", kwargs={"room_id": room.id})
response = client.post(url, data={"name": "test", "location": "BELFORT"})
assertRedirects(response, url)
room.refresh_from_db()
assert room.club == club
assert room.name == "test"
assert room.location == "BELFORT"
def test_permission_denied(self, client: Client):
club = baker.make(Club)
room = baker.make(Room, club=club)
client.force_login(baker.make(User))
url = reverse("reservation:room_edit", kwargs={"room_id": room.id})
response = client.get(url)
assert response.status_code == 403
response = client.post(url, data={"name": "test", "location": "BELFORT"})
assert response.status_code == 403
@pytest.mark.django_db
class TestUpdateRoomForm:
def test_form_club_edition_rights(self):
"""The club field should appear only if the request user can edit it."""
room = baker.make(Room)
perm = Permission.objects.get(codename="change_room")
user_authorized = baker.make(User, user_permissions=[perm])
assert "club" in RoomUpdateForm(request_user=user_authorized).fields
user_forbidden = baker.make(User, groups=[room.club.board_group])
assert "club" not in RoomUpdateForm(request_user=user_forbidden).fields

Просмотреть файл

@ -1,42 +0,0 @@
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 pytest_django.asserts import assertNumQueries
from core.models import User
from reservation.models import Room
@pytest.mark.django_db
class TestFetchRoom:
@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)
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,
"location": room.location,
"club": {"id": room.club.id, "name": room.club.name},
}
for room in rooms
]
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"))

Просмотреть файл

@ -17,10 +17,8 @@ from reservation.models import ReservationSlot, Room
class TestFetchReservationSlotsApi:
@pytest.fixture
def user(self):
return baker.make(
User,
user_permissions=[Permission.objects.get(codename="view_reservationslot")],
)
perm = Permission.objects.get(codename="view_reservationslot")
return baker.make(User, user_permissions=[perm])
def test_fetch_simple(self, client: Client, user: User):
slots = baker.make(ReservationSlot, _quantity=5, _bulk_create=True)
@ -55,6 +53,67 @@ class TestFetchReservationSlotsApi:
client.get(reverse("api:fetch_reservation_slots"))
@pytest.mark.django_db
class TestUpdateReservationSlotApi:
@pytest.fixture
def user(self):
perm = Permission.objects.get(codename="change_reservationslot")
return baker.make(User, user_permissions=[perm])
@pytest.fixture
def slot(self):
return baker.make(
ReservationSlot,
start_at=now() + timedelta(hours=2),
end_at=now() + timedelta(hours=4),
)
def test_ok(self, client: Client, user: User, slot: ReservationSlot):
client.force_login(user)
new_start = (slot.start_at + timedelta(hours=1)).replace(microsecond=0)
response = client.patch(
reverse("api:change_reservation_slot", kwargs={"slot_id": slot.id}),
{"start_at": new_start, "end_at": new_start + timedelta(hours=2)},
content_type="application/json",
)
assert response.status_code == 200
slot.refresh_from_db()
assert slot.start_at.replace(microsecond=0) == new_start
assert slot.end_at.replace(microsecond=0) == new_start + timedelta(hours=2)
def test_change_past_event(self, client, user: User, slot: ReservationSlot):
"""Test that moving a slot that already began is impossible."""
client.force_login(user)
new_start = now() - timedelta(hours=1)
response = client.patch(
reverse("api:change_reservation_slot", kwargs={"slot_id": slot.id}),
{"start_at": new_start, "end_at": new_start + timedelta(hours=2)},
content_type="application/json",
)
assert response.status_code == 422
def test_move_event_to_occupied_slot(
self, client: Client, user: User, slot: ReservationSlot
):
client.force_login(user)
other_slot = baker.make(
ReservationSlot,
room=slot.room,
start_at=slot.end_at + timedelta(hours=1),
end_at=slot.end_at + timedelta(hours=3),
)
response = client.patch(
reverse("api:change_reservation_slot", kwargs={"slot_id": slot.id}),
{
"start_at": other_slot.start_at - timedelta(hours=1),
"end_at": other_slot.start_at + timedelta(hours=1),
},
content_type="application/json",
)
assert response.status_code == 409
@pytest.mark.django_db
class TestReservationForm:
def test_ok(self):
@ -109,3 +168,40 @@ class TestReservationForm:
assert form.errors == {
"__all__": ["Il y a déjà une réservation sur ce créneau."]
}
@pytest.mark.django_db
class TestCreateReservationSlot:
@pytest.fixture
def user(self):
perms = Permission.objects.filter(
codename__in=["add_reservationslot", "view_reservationslot"]
)
return baker.make(User, user_permissions=list(perms))
def test_ok(self, client: Client, user: User):
client.force_login(user)
start = now() + timedelta(hours=2)
end = start + timedelta(hours=1)
room = baker.make(Room)
response = client.post(
reverse("reservation:make_reservation"),
{"room": room.id, "start_at": start, "end_at": end},
)
assert response.status_code == 200
assert response.headers.get("HX-Redirect", "") == reverse("reservation:main")
slot = ReservationSlot.objects.filter(room=room).last()
assert slot is not None
assert slot.start_at == start
assert slot.end_at == end
assert slot.author == user
def test_permissions_denied(self, client: Client):
client.force_login(baker.make(User))
start = now() + timedelta(hours=2)
end = start + timedelta(hours=1)
response = client.post(
reverse("reservation:make_reservation"),
{"room": baker.make(Room), "start_at": start, "end_at": end},
)
assert response.status_code == 403

Просмотреть файл

@ -2,7 +2,7 @@
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse_lazy
from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, DeleteView, TemplateView, UpdateView
@ -29,14 +29,13 @@ class ReservationFragment(PermissionRequiredMixin, FragmentMixin, CreateView):
class ReservationScheduleView(PermissionRequiredMixin, UseFragmentsMixin, TemplateView):
template_name = "reservation/schedule.jinja"
permission_required = "reservation.view_room"
permission_required = "reservation.view_reservationslot"
fragments = {"add_slot_fragment": ReservationFragment}
class RoomCreateView(SuccessMessageMixin, PermissionRequiredMixin, CreateView):
class RoomCreateView(PermissionRequiredMixin, CreateView):
form_class = RoomCreateForm
template_name = "core/create.jinja"
success_message = _("%(name)s was created successfully")
permission_required = "reservation.add_room"
def get_initial(self):
@ -47,6 +46,9 @@ class RoomCreateView(SuccessMessageMixin, PermissionRequiredMixin, CreateView):
init["club"] = Club.objects.filter(id=int(club_id)).first()
return init
def get_success_url(self):
return reverse("club:tools", kwargs={"club_id": self.object.club_id})
class RoomUpdateView(SuccessMessageMixin, CanEditMixin, UpdateView):
model = Room