diff --git a/club/templates/club/club_tools.jinja b/club/templates/club/club_tools.jinja
index a69d5c266..dbcb42970 100644
--- a/club/templates/club/club_tools.jinja
+++ b/club/templates/club/club_tools.jinja
@@ -1,7 +1,12 @@
{% extends "core/base.jinja" %}
+{% from "reservation/macros.jinja" import room_detail %}
+
+{% block additional_css %}
+
+{% endblock %}
{% block content %}
-
{% trans %}Communication:{% endtrans %}
+
{% trans %}Reservable rooms{% endtrans %}
+
+ {% trans %}Add a room{% endtrans %}
+
+ {%- if reservable_rooms|length > 0 -%}
+
+ {%- for room in reservable_rooms -%}
+ {{ room_detail(
+ room,
+ can_edit=user.can_edit(room),
+ can_delete=request.user.has_perm("reservation.delete_room")
+ ) }}
+ {%- endfor -%}
+
+ {%- else -%}
+
+ {% trans %}This club manages no reservable room{% endtrans %}
+
+ {%- endif -%}
{% trans %}Counters:{% endtrans %}
- {% for c in object.counters.filter(type="OFFICE") %}
- - {{ c }}:
- View
- Edit
+ {% for counter in counters %}
+
- {{ counter }}:
+ View
+ Edit
{% endfor %}
diff --git a/club/views.py b/club/views.py
index 6f2c77160..c67f9b193 100644
--- a/club/views.py
+++ b/club/views.py
@@ -309,6 +309,12 @@ class ClubToolsView(ClubTabsMixin, CanEditMixin, DetailView):
template_name = "club/club_tools.jinja"
current_tab = "tools"
+ def get_context_data(self, **kwargs):
+ return super().get_context_data(**kwargs) | {
+ "reservable_rooms": list(self.object.reservable_rooms.all()),
+ "counters": list(self.object.counters.filter(type="OFFICE")),
+ }
+
class ClubAddMembersFragment(
FragmentMixin, PermissionRequiredMixin, SuccessMessageMixin, CreateView
diff --git a/com/static/com/css/news-list.scss b/com/static/com/css/news-list.scss
index e53fbc5c8..885ec0bad 100644
--- a/com/static/com/css/news-list.scss
+++ b/com/static/com/css/news-list.scss
@@ -84,7 +84,6 @@
font-size: 85%;
#links_content {
- overflow: auto;
box-shadow: $shadow-color 1px 1px 1px;
min-height: 20em;
padding: 1em;
diff --git a/com/templates/com/news_list.jinja b/com/templates/com/news_list.jinja
index 975fb2acc..5e2645366 100644
--- a/com/templates/com/news_list.jinja
+++ b/com/templates/com/news_list.jinja
@@ -1,9 +1,11 @@
{% extends "core/base.jinja" %}
{% from "com/macros.jinja" import news_moderation_alert %}
+{% block title %}AE UTBM{% endblock %}
+
{% block additional_css %}
-
+
{# Atom feed discovery, not really css but also goes there #}
@@ -213,6 +215,12 @@
{% trans %}Matmatronch{% endtrans %}
+ {% if user.has_perm("reservation.view_reservationslot") %}
+
+
+ {% trans %}Room reservation{% endtrans %}
+
+ {% endif %}
{% trans %}Elections{% endtrans %}
diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py
index 38da5e959..e82a226e5 100644
--- a/core/management/commands/populate.py
+++ b/core/management/commands/populate.py
@@ -892,7 +892,11 @@ def _create_groups(self) -> PopulatedGroups:
subscribers = Group.objects.create(name="Cotisants")
subscribers.permissions.add(
- *list(perms.filter(codename__in=["add_news", "add_uecomment"]))
+ *list(
+ perms.filter(
+ codename__in=["add_news", "add_uecomment", "view_reservationslot"]
+ )
+ )
)
old_subscribers = Group.objects.create(name="Anciens cotisants")
old_subscribers.permissions.add(
diff --git a/core/management/commands/populate_more.py b/core/management/commands/populate_more.py
index 14b1c59af..58d1c06f6 100644
--- a/core/management/commands/populate_more.py
+++ b/core/management/commands/populate_more.py
@@ -1,6 +1,7 @@
import random
from datetime import date, timedelta
from datetime import timezone as tz
+from math import ceil
from typing import Iterator
from dateutil.relativedelta import relativedelta
@@ -25,6 +26,7 @@
)
from forum.models import Forum, ForumMessage, ForumTopic
from pedagogy.models import UE
+from reservation.models import ReservationSlot, Room
from subscription.models import Subscription
@@ -42,45 +44,20 @@ def handle(self, *args, **options):
self.stdout.write("Creating users...")
users = self.create_users()
self.create_bans(random.sample(users, k=len(users) // 200)) # 0.5% of users
+ # len(subscribers) is approximately 480
subscribers = random.sample(users, k=int(0.8 * len(users)))
self.stdout.write("Creating subscriptions...")
self.create_subscriptions(subscribers)
self.stdout.write("Creating club memberships...")
- users_qs = User.objects.filter(id__in=[s.id for s in subscribers])
- subscribers_now = list(
- users_qs.annotate(
- filter=Exists(
- Subscription.objects.filter(
- member_id=OuterRef("pk"), subscription_end__gte=now()
- )
- )
- )
- )
- old_subscribers = list(
- users_qs.annotate(
- filter=Exists(
- Subscription.objects.filter(
- member_id=OuterRef("pk"), subscription_end__lt=now()
- )
- )
- )
- )
- self.make_club(
- Club.objects.get(id=settings.SITH_MAIN_CLUB_ID),
- random.sample(subscribers_now, k=min(30, len(subscribers_now))),
- random.sample(old_subscribers, k=min(60, len(old_subscribers))),
- )
- self.make_club(
- Club.objects.get(name="Troll Penché"),
- random.sample(subscribers_now, k=min(20, len(subscribers_now))),
- random.sample(old_subscribers, k=min(80, len(old_subscribers))),
- )
+ self.create_club_memberships(subscribers)
+ self.stdout.write("Creating rooms and reservation...")
+ self.create_resources_and_reservations(random.sample(subscribers, k=40))
self.stdout.write("Creating uvs...")
self.create_ues()
self.stdout.write("Creating products...")
self.create_products()
self.stdout.write("Creating sales and refills...")
- sellers = random.sample(list(User.objects.all()), 100)
+ sellers = list(User.objects.order_by("?")[:100])
self.create_sales(sellers)
self.stdout.write("Creating permanences...")
self.create_permanences(sellers)
@@ -215,6 +192,97 @@ def zip_roles(users: list[User]) -> Iterator[tuple[User, ClubRole]]:
memberships = Membership.objects.bulk_create(memberships)
Membership._add_club_groups(memberships)
+ def create_club_memberships(self, users: list[User]):
+ users_qs = User.objects.filter(id__in=[s.id for s in users])
+ subscribers_now = list(
+ users_qs.annotate(
+ filter=Exists(
+ Subscription.objects.filter(
+ member_id=OuterRef("pk"), subscription_end__gte=now()
+ )
+ )
+ )
+ )
+ old_subscribers = list(
+ users_qs.annotate(
+ filter=Exists(
+ Subscription.objects.filter(
+ member_id=OuterRef("pk"), subscription_end__lt=now()
+ )
+ )
+ )
+ )
+ self.make_club(
+ Club.objects.get(id=settings.SITH_MAIN_CLUB_ID),
+ random.sample(subscribers_now, k=min(30, len(subscribers_now))),
+ random.sample(old_subscribers, k=min(60, len(old_subscribers))),
+ )
+ self.make_club(
+ Club.objects.get(name="Troll Penché"),
+ random.sample(subscribers_now, k=min(20, len(subscribers_now))),
+ random.sample(old_subscribers, k=min(80, len(old_subscribers))),
+ )
+
+ def create_resources_and_reservations(self, users: list[User]):
+ """Generate reservable rooms and reservations slots for those rooms.
+
+ Contrary to the other data generator,
+ this one generates more data than what is expected on the real db.
+ """
+ ae = Club.objects.get(id=settings.SITH_MAIN_CLUB_ID)
+ pdf = Club.objects.get(id=settings.SITH_PDF_CLUB_ID)
+ troll = Club.objects.get(name="Troll Penché")
+ rooms = [
+ Room(
+ name=name,
+ club=club,
+ location=location,
+ description=self.faker.text(100),
+ )
+ for name, club, location in [
+ ("Champi", ae, "BELFORT"),
+ ("Muzik", ae, "BELFORT"),
+ ("Pôle Tech", ae, "BELFORT"),
+ ("Jolly", troll, "BELFORT"),
+ ("Cookut", pdf, "BELFORT"),
+ ("Lucky", pdf, "BELFORT"),
+ ("Potards", pdf, "SEVENANS"),
+ ("Bureau AE", ae, "SEVENANS"),
+ ]
+ ]
+ rooms = Room.objects.bulk_create(rooms)
+ reservations = []
+ for room in rooms:
+ # how much people use this room.
+ # The higher the number, the more reservations exist,
+ # the smaller the interval between two slot is,
+ # and the more future reservations have already been made ahead of time
+ affluence = random.randint(2, 6)
+ slot_start = make_aware(self.faker.past_datetime("-5y").replace(minute=0))
+ generate_until = make_aware(
+ self.faker.future_datetime(timedelta(days=1) * affluence**2)
+ )
+ while slot_start < generate_until:
+ if slot_start.hour < 8:
+ # if a reservation would start in the middle of the night
+ # make it start the next morning instead
+ slot_start += timedelta(hours=10 - slot_start.hour)
+ duration = timedelta(minutes=15) * (1 + int(random.gammavariate(3, 2)))
+ reservations.append(
+ ReservationSlot(
+ room=room,
+ author=random.choice(users),
+ start_at=slot_start,
+ end_at=slot_start + duration,
+ created_at=slot_start - self.faker.time_delta("+7d"),
+ )
+ )
+ slot_start += duration + (
+ timedelta(minutes=15) * ceil(random.expovariate(affluence / 192))
+ )
+ reservations.sort(key=lambda slot: slot.created_at)
+ ReservationSlot.objects.bulk_create(reservations)
+
def create_ues(self):
root = User.objects.get(username="root")
categories = ["CS", "TM", "OM", "QC", "EC"]
@@ -415,7 +483,7 @@ def create_permanences(self, sellers: list[User]):
Permanency.objects.bulk_create(perms)
def create_forums(self):
- forumers = random.sample(list(User.objects.all()), 100)
+ forumers = list(User.objects.order_by("?")[:100])
most_actives = random.sample(forumers, 10)
categories = list(Forum.objects.filter(is_category=True))
new_forums = [
@@ -433,7 +501,7 @@ def create_forums(self):
for _ in range(100)
]
ForumTopic.objects.bulk_create(new_topics)
- topics = list(ForumTopic.objects.all())
+ topics = list(ForumTopic.objects.values_list("id", flat=True))
def get_author():
if random.random() > 0.5:
@@ -441,7 +509,7 @@ def get_author():
return random.choice(forumers)
messages = []
- for t in topics:
+ for topic_id in topics:
nb_messages = max(1, int(random.normalvariate(mu=90, sigma=50)))
dates = sorted(
[
@@ -453,7 +521,7 @@ def get_author():
messages.extend(
[
ForumMessage(
- topic=t,
+ topic_id=topic_id,
author=get_author(),
date=d,
message="\n\n".join(
diff --git a/core/static/bundled/base-bundle-index.ts b/core/static/bundled/base-bundle-index.ts
index 4dc085ad7..e3e6fcec5 100644
--- a/core/static/bundled/base-bundle-index.ts
+++ b/core/static/bundled/base-bundle-index.ts
@@ -6,10 +6,12 @@
* for more efficient tree-shaking and gzip compression.
*/
+import { morph } from "@alpinejs/morph";
import sort from "@alpinejs/sort";
import Alpine from "alpinejs";
import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill";
import htmx from "htmx.org";
+import "htmx-ext-alpine-morph";
import { limitedChoices } from "#core:alpine/limited-choices";
import { expireOldStorage } from "#core:core/localstorage";
import { default as navbar } from "#core:core/navbar";
@@ -27,7 +29,7 @@ declare module "alpinejs" {
}
}
-Alpine.plugin([sort, limitedChoices, notifications]);
+Alpine.plugin([sort, limitedChoices, morph, notifications]);
// biome-ignore lint/style/useNamingConvention: it's how it's named
Object.assign(window, { Alpine });
diff --git a/com/static/com/components/ics-calendar.scss b/core/static/core/components/calendar.scss
similarity index 76%
rename from com/static/com/components/ics-calendar.scss
rename to core/static/core/components/calendar.scss
index 74a763974..4f1512897 100644
--- a/com/static/com/components/ics-calendar.scss
+++ b/core/static/core/components/calendar.scss
@@ -16,57 +16,17 @@
--event-details-padding: 20px;
--event-details-border: 1px solid #EEEEEE;
--event-details-border-radius: 4px;
- --event-details-box-shadow: 0px 6px 20px 4px rgb(0 0 0 / 16%);
+ --event-details-box-shadow: 0 6px 20px 4px rgb(0 0 0 / 16%);
--event-details-max-width: 600px;
--event-recurring-internal-color: #6f69cd;
--event-recurring-unpublished-color: orange;
}
-ics-calendar {
+ics-calendar,
+room-scheduler {
border: none;
box-shadow: none;
- #event-details {
- z-index: 10;
- max-width: 1151px;
- position: absolute;
-
- .event-details-container {
- display: flex;
- color: black;
- flex-direction: column;
- min-width: 200px;
- max-width: var(--event-details-max-width);
- padding: var(--event-details-padding);
- border: var(--event-details-border);
- border-radius: var(--event-details-border-radius);
- background-color: var(--event-details-background-color);
- box-shadow: var(--event-details-box-shadow);
- gap: 20px;
- }
-
- .event-detail-row-icon {
- margin-left: 10px;
- margin-right: 20px;
- align-content: center;
- align-self: center;
- }
-
- .event-details-row {
- display: flex;
- align-items: start;
- }
-
- .event-details-row-content {
- display: flex;
- align-items: start;
- flex-direction: row;
- background-color: var(--event-details-background-color);
- margin-top: 0px;
- margin-bottom: 4px;
- }
- }
-
a.fc-col-header-cell-cushion,
a.fc-col-header-cell-cushion:hover {
color: black;
@@ -81,12 +41,16 @@ ics-calendar {
overflow: visible; // Show events on multiple days
}
+ td, th {
+ text-align: unset;
+ }
+
//Reset from style.scss
table {
box-shadow: none;
- border-radius: 0px;
- -moz-border-radius: 0px;
- margin: 0px;
+ border-radius: 0;
+ -moz-border-radius: 0;
+ margin: 0;
}
// Reset from style.scss
@@ -96,7 +60,7 @@ ics-calendar {
}
// Reset from style.scss
- tbody>tr {
+ tbody > tr {
&:nth-child(even):not(.highlight) {
background: white;
}
@@ -119,27 +83,101 @@ ics-calendar {
transition: 500ms ease-out;
}
- .fc .fc-getCalendarLink-button {
- margin-right: 0.5rem;
- }
+}
- .fc .fc-helpButton-button {
- border-radius: 70%;
- padding-left: 0.5rem;
- padding-right: 0.5rem;
- background-color: rgba(0, 0, 0, 0.8);
- transition: 100ms ease-out;
- width: 30px;
- height: 30px;
- font-size: 11px;
+ics-calendar {
+ #event-details {
+ z-index: 10;
+ max-width: 1151px;
+ position: absolute;
+
+ .event-details-container {
+ display: flex;
+ color: black;
+ flex-direction: column;
+ min-width: 200px;
+ max-width: var(--event-details-max-width);
+ padding: var(--event-details-padding);
+ border: var(--event-details-border);
+ border-radius: var(--event-details-border-radius);
+ background-color: var(--event-details-background-color);
+ box-shadow: var(--event-details-box-shadow);
+ gap: 20px;
+ }
+
+ .event-detail-row-icon {
+ margin-left: 10px;
+ margin-right: 20px;
+ align-content: center;
+ align-self: center;
+ }
+
+ .event-details-row {
+ display: flex;
+ align-items: start;
+ }
+
+ .event-details-row-content {
+ display: flex;
+ align-items: start;
+ flex-direction: row;
+ background-color: var(--event-details-background-color);
+ margin-top: 0;
+ margin-bottom: 4px;
+ }
}
+}
+// Reset from style.scss
+thead {
+ background-color: white;
+ color: black;
+}
- .fc .fc-helpButton-button:hover {
- background-color: rgba(20, 20, 20, 0.6);
+// Reset from style.scss
+tbody > tr {
+ &:nth-child(even):not(.highlight) {
+ background: white;
}
}
+.fc .fc-toolbar.fc-footer-toolbar {
+ margin-bottom: 0.5em;
+}
+
+button.text-copy,
+button.text-copy:focus,
+button.text-copy:hover {
+ background-color: #67AE6E !important;
+ transition: 500ms ease-in;
+}
+
+button.text-copied,
+button.text-copied:focus,
+button.text-copied:hover {
+ transition: 500ms ease-out;
+}
+
+.fc .fc-getCalendarLink-button {
+ margin-right: 0.5rem;
+}
+
+.fc .fc-helpButton-button {
+ border-radius: 70%;
+ padding-left: 0.5rem;
+ padding-right: 0.5rem;
+ background-color: rgba(0, 0, 0, 0.8);
+ transition: 100ms ease-out;
+ width: 30px;
+ height: 30px;
+ font-size: 11px;
+}
+
+
+.fc .fc-helpButton-button:hover {
+ background-color: rgba(20, 20, 20, 0.6);
+}
+
.tooltip.calendar-copy-tooltip {
opacity: 1;
transition: opacity 500ms ease-in;
diff --git a/core/static/core/components/card.scss b/core/static/core/components/card.scss
index 941b32a5f..c2f0ffa30 100644
--- a/core/static/core/components/card.scss
+++ b/core/static/core/components/card.scss
@@ -16,6 +16,13 @@
}
}
+.card-group {
+ display: flex;
+ gap: 15px;
+ margin-bottom: 30px;
+ flex-wrap: wrap;
+}
+
.card {
background-color: $primary-neutral-light-color;
border-radius: 5px;
@@ -92,13 +99,23 @@
}
@media screen and (max-width: 765px) {
- @include row-layout
+ @include row-layout;
}
// When combined with card, card-row display the card in a row layout,
// whatever the size of the screen.
&.card-row {
- @include row-layout
+ @include row-layout;
+
+ &.card-row-m {
+ //width: 50%;
+ max-width: 50%;
+ }
+
+ &.card-row-s {
+ //width: 33%;
+ max-width: 33%;
+ }
}
}
diff --git a/core/static/core/tooltips.scss b/core/static/core/tooltips.scss
index 3efce0ce9..15e7f1202 100644
--- a/core/static/core/tooltips.scss
+++ b/core/static/core/tooltips.scss
@@ -10,10 +10,9 @@
border-radius: 5px;
padding: 5px 10px;
position: absolute;
- white-space: nowrap;
opacity: 0;
transition: opacity 500ms ease-out;
-
+ width: max-content;
white-space: normal;
left: 0;
diff --git a/core/views/forms.py b/core/views/forms.py
index e46daf925..510370352 100644
--- a/core/views/forms.py
+++ b/core/views/forms.py
@@ -40,9 +40,8 @@
DateInput,
DateTimeInput,
TextInput,
- Widget,
)
-from django.utils.timezone import now
+from django.utils.timezone import localtime, now
from django.utils.translation import gettext_lazy as _
from phonenumber_field.widgets import RegionalPhoneNumberWidget
from PIL import Image
@@ -101,8 +100,8 @@ class FutureDateTimeField(forms.DateTimeField):
default_validators = [validate_future_timestamp]
- def widget_attrs(self, widget: Widget) -> dict[str, str]:
- return {"min": widget.format_value(now())}
+ def widget_attrs(self, widget: forms.Widget) -> dict[str, str]:
+ return {"min": widget.format_value(localtime())}
# Forms
diff --git a/core/views/mixins.py b/core/views/mixins.py
index 7ba92535d..5a36fd18e 100644
--- a/core/views/mixins.py
+++ b/core/views/mixins.py
@@ -78,7 +78,7 @@ def some_view(request):
return render(
request,
"app/template.jinja",
- context={"fragment": fragment(request)
+ context={"fragment": fragment(request)}
}
# in urls.py
diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po
index 28f562b1f..a456189a7 100644
--- a/locale/fr/LC_MESSAGES/django.po
+++ b/locale/fr/LC_MESSAGES/django.po
@@ -304,6 +304,11 @@ msgstr "date de fin"
msgid "role"
msgstr "rôle"
+#: club/models.py core/models.py counter/models.py election/models.py
+#: forum/models.py reservation/models.py
+msgid "description"
+msgstr "description"
+
#: club/models.py
msgid "past member"
msgstr "ancien membre"
@@ -755,6 +760,18 @@ msgstr "Nouveau Trombi"
msgid "Posters"
msgstr "Affiches"
+#: club/templates/club/club_tools.jinja
+msgid "Reservable rooms"
+msgstr "Salles réservables"
+
+#: club/templates/club/club_tools.jinja
+msgid "Add a room"
+msgstr "Ajouter une salle"
+
+#: club/templates/club/club_tools.jinja
+msgid "This club manages no reservable room"
+msgstr "Ce club ne gère pas de salle réservable"
+
#: club/templates/club/club_tools.jinja
msgid "Counters:"
msgstr "Comptoirs : "
@@ -1054,7 +1071,7 @@ msgstr "Une description plus détaillée et exhaustive de l'évènement."
msgid "The club which organizes the event."
msgstr "Le club qui organise l'évènement."
-#: com/models.py pedagogy/models.py trombi/models.py
+#: com/models.py pedagogy/models.py reservation/models.py trombi/models.py
msgid "author"
msgstr "auteur"
@@ -1347,6 +1364,11 @@ msgstr "Emploi du temps"
msgid "Matmatronch"
msgstr "Matmatronch"
+#: com/templates/com/news_list.jinja
+#: reservation/templates/reservation/schedule.jinja
+msgid "Room reservation"
+msgstr "Réservation de salle"
+
#: com/templates/com/news_list.jinja core/templates/core/base/navbar.jinja
#: core/templates/core/user_tools.jinja
msgid "Elections"
@@ -2210,6 +2232,7 @@ msgstr "Confirmation"
#: core/templates/core/file_delete_confirm.jinja
#: counter/templates/counter/counter_click.jinja
#: counter/templates/counter/fragments/delete_student_card.jinja
+#: reservation/templates/reservation/fragments/create_reservation.jinja
#: sas/templates/sas/ask_picture_removal.jinja
msgid "Cancel"
msgstr "Annuler"
@@ -3371,7 +3394,7 @@ msgstr "Mettre à True si le mail a reçu une erreur"
msgid "The operation that emptied the account."
msgstr "L'opération qui a vidé le compte."
-#: counter/models.py pedagogy/models.py
+#: counter/models.py pedagogy/models.py reservation/models.py
msgid "comment"
msgstr "commentaire"
@@ -5150,6 +5173,73 @@ msgstr "Signaler ce commentaire"
msgid "Edit UE"
msgstr "Éditer l'UE"
+#: reservation/forms.py
+msgid "The start must be set before the end"
+msgstr "Le début doit être placé avant la fin"
+
+#: reservation/models.py
+msgid "room name"
+msgstr "Nom de la salle"
+
+#: reservation/models.py
+msgid "room owner"
+msgstr "propriétaire de la salle"
+
+#: reservation/models.py
+msgid "The club which manages this room"
+msgstr "Le club qui gère cette salle"
+
+#: reservation/models.py
+msgid "site"
+msgstr "site"
+
+#: reservation/models.py
+msgid "reservable room"
+msgstr "salle réservable"
+
+#: reservation/models.py
+msgid "reservable rooms"
+msgstr "salles réservables"
+
+#: reservation/models.py
+msgid "reserved room"
+msgstr "salle réservée"
+
+#: reservation/models.py
+msgid "slot start"
+msgstr "début du créneau"
+
+#: reservation/models.py
+msgid "slot end"
+msgstr "fin du créneau"
+
+#: reservation/models.py
+msgid "reservation slot"
+msgstr "créneau de réservation"
+
+#: reservation/models.py
+msgid "reservation slots"
+msgstr "créneaux de réservation"
+
+#: reservation/models.py
+msgid "There is already a reservation on this slot."
+msgstr "Il y a déjà une réservation sur ce créneau."
+
+#: reservation/templates/reservation/fragments/create_reservation.jinja
+msgid "Book a room"
+msgstr "Réserver une salle"
+
+#: reservation/templates/reservation/schedule.jinja
+msgid "You can book a room by selecting a free slot in the calendar."
+msgstr ""
+"Vous pouvez réserver une salle en sélectionnant un emplacement libre dans le "
+"calendrier."
+
+#: reservation/views.py
+#, python-format
+msgid "%(name)s was updated successfully"
+msgstr "%(name)s a été mis à jour avec succès"
+
#: rootplace/forms.py
msgid "User that will be kept"
msgstr "Utilisateur qui sera conservé"
diff --git a/locale/fr/LC_MESSAGES/djangojs.po b/locale/fr/LC_MESSAGES/djangojs.po
index 78e152020..5a36d26a8 100644
--- a/locale/fr/LC_MESSAGES/djangojs.po
+++ b/locale/fr/LC_MESSAGES/djangojs.po
@@ -263,6 +263,14 @@ msgstr "Types de produits réordonnés !"
msgid "Product type reorganisation failed with status code : %d"
msgstr "La réorganisation des types de produit a échoué avec le code : %d"
+#: reservation/static/bundled/reservation/components/room-scheduler-index.ts
+msgid "Rooms"
+msgstr "Salles"
+
+#: reservation/static/bundled/reservation/slot-reservation-index.ts
+msgid "This slot has been successfully moved"
+msgstr "Ce créneau a été bougé avec succès"
+
#: sas/static/bundled/sas/pictures-download-index.ts
msgid "pictures.%(extension)s"
msgstr "photos.%(extension)s"
diff --git a/package-lock.json b/package-lock.json
index 8778d436d..003c54c90 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"version": "3",
"license": "GPL-3.0-only",
"dependencies": {
+ "@alpinejs/morph": "^3.14.9",
"@alpinejs/sort": "^3.15.12",
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
"@floating-ui/dom": "^1.7.6",
@@ -16,7 +17,10 @@
"@fullcalendar/core": "^6.1.20",
"@fullcalendar/daygrid": "^6.1.20",
"@fullcalendar/icalendar": "^6.1.20",
+ "@fullcalendar/interaction": "^6.1.20",
"@fullcalendar/list": "^6.1.20",
+ "@fullcalendar/resource": "^6.1.20",
+ "@fullcalendar/resource-timeline": "^6.1.20",
"@sentry/browser": "^10.53.1",
"@zip.js/zip.js": "^2.8.26",
"3d-force-graph": "^1.80.0",
@@ -30,6 +34,7 @@
"easymde": "^2.21.0",
"glob": "^13.0.6",
"html2canvas": "^1.4.1",
+ "htmx-ext-alpine-morph": "^2.0.1",
"htmx.org": "^2.0.10",
"js-cookie": "^3.0.7",
"lit-html": "^3.3.3",
@@ -53,6 +58,12 @@
"vite": "^8.0.13"
}
},
+ "node_modules/@alpinejs/morph": {
+ "version": "3.15.12",
+ "resolved": "https://registry.npmjs.org/@alpinejs/morph/-/morph-3.15.12.tgz",
+ "integrity": "sha512-x/QZUsbE3uMAVVzsEjSZf2xbjgzIqUZ+HW9GUtePheyIrOgHlqZETXBq1DpXg6H8kA0099SyAI9YWnKFDS31cg==",
+ "license": "MIT"
+ },
"node_modules/@alpinejs/sort": {
"version": "3.15.12",
"resolved": "https://registry.npmjs.org/@alpinejs/sort/-/sort-3.15.12.tgz",
@@ -212,9 +223,9 @@
}
},
"node_modules/@babel/helper-define-polyfill-provider": {
- "version": "0.6.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.7.tgz",
- "integrity": "sha512-6Fqi8MtQ/PweQ9xvux65emkLQ83uB+qAVtfHkC9UodyHMIZdxNI01HjLCLUtybElp2KY2XNE0nOgyP1E1vXw9w==",
+ "version": "0.6.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.6.tgz",
+ "integrity": "sha512-mOAsxeeKkUKayvZR3HeTYD/fICpCPLJrU5ZjelT/PA6WHtNDBOE436YiaEUvHN454bRM3CebhDsIpieCc4texA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1880,6 +1891,15 @@
"ical.js": "^1.4.0"
}
},
+ "node_modules/@fullcalendar/interaction": {
+ "version": "6.1.20",
+ "resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.20.tgz",
+ "integrity": "sha512-p6txmc5txL0bMiPaJxe2ip6o0T384TyoD2KGdsU6UjZ5yoBlaY+dg7kxfnYKpYMzEJLG58n+URrHr2PgNL2fyA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@fullcalendar/core": "~6.1.20"
+ }
+ },
"node_modules/@fullcalendar/list": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/list/-/list-6.1.20.tgz",
@@ -1889,6 +1909,67 @@
"@fullcalendar/core": "~6.1.20"
}
},
+ "node_modules/@fullcalendar/premium-common": {
+ "version": "6.1.20",
+ "resolved": "https://registry.npmjs.org/@fullcalendar/premium-common/-/premium-common-6.1.20.tgz",
+ "integrity": "sha512-rT+AitNnRyZuFEtYvsB1OJ2g1Bq2jmTR6qdn/dEU6LwkIj/4L499goLtMOena/JyJ31VBztdHrccX//36QrY3w==",
+ "license": "SEE LICENSE IN LICENSE.md",
+ "peerDependencies": {
+ "@fullcalendar/core": "~6.1.20"
+ }
+ },
+ "node_modules/@fullcalendar/resource": {
+ "version": "6.1.20",
+ "resolved": "https://registry.npmjs.org/@fullcalendar/resource/-/resource-6.1.20.tgz",
+ "integrity": "sha512-vpQs1eYJbc1zGOzF3obVVr+XsHTMTG7STKVQBEGy3AeFgfosRkUz+3DUawmy98vSjJUYOAQHO+pWW0ek0n5g0w==",
+ "license": "SEE LICENSE IN LICENSE.md",
+ "dependencies": {
+ "@fullcalendar/premium-common": "~6.1.20"
+ },
+ "peerDependencies": {
+ "@fullcalendar/core": "~6.1.20"
+ }
+ },
+ "node_modules/@fullcalendar/resource-timeline": {
+ "version": "6.1.20",
+ "resolved": "https://registry.npmjs.org/@fullcalendar/resource-timeline/-/resource-timeline-6.1.20.tgz",
+ "integrity": "sha512-HAlM/I+9xJPzZx3Wry7l5oibc8n5Pv/iL8tp2dxUu/0zqS0UqADbHItJucuANfDDeL7PEbCbh/uFx9VvzRUIkQ==",
+ "license": "SEE LICENSE IN LICENSE.md",
+ "dependencies": {
+ "@fullcalendar/premium-common": "~6.1.20",
+ "@fullcalendar/scrollgrid": "~6.1.20",
+ "@fullcalendar/timeline": "~6.1.20"
+ },
+ "peerDependencies": {
+ "@fullcalendar/core": "~6.1.20",
+ "@fullcalendar/resource": "~6.1.20"
+ }
+ },
+ "node_modules/@fullcalendar/scrollgrid": {
+ "version": "6.1.20",
+ "resolved": "https://registry.npmjs.org/@fullcalendar/scrollgrid/-/scrollgrid-6.1.20.tgz",
+ "integrity": "sha512-M55m0hxpou4IPObto5f0nVcXvIj3rkSTba0ypclSFDwBz3JxuCPS6l8kaUznqlZCr2Ld/HFJr+jwyvY070AafQ==",
+ "license": "SEE LICENSE IN LICENSE.md",
+ "dependencies": {
+ "@fullcalendar/premium-common": "~6.1.20"
+ },
+ "peerDependencies": {
+ "@fullcalendar/core": "~6.1.20"
+ }
+ },
+ "node_modules/@fullcalendar/timeline": {
+ "version": "6.1.20",
+ "resolved": "https://registry.npmjs.org/@fullcalendar/timeline/-/timeline-6.1.20.tgz",
+ "integrity": "sha512-yhTgMNDWfB+XqEUTLWrpPjM4fcvGYLOA9DvTp1ysdeqhRGoZnRK9Iv2WW5BaKT+VXhXoAPrj2Ud/lXt6youWAQ==",
+ "license": "SEE LICENSE IN LICENSE.md",
+ "dependencies": {
+ "@fullcalendar/premium-common": "~6.1.20",
+ "@fullcalendar/scrollgrid": "~6.1.20"
+ },
+ "peerDependencies": {
+ "@fullcalendar/core": "~6.1.20"
+ }
+ },
"node_modules/@hey-api/codegen-core": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.7.4.tgz",
@@ -2110,9 +2191,9 @@
"license": "Apache-2.0"
},
"node_modules/@oxc-project/types": {
- "version": "0.130.0",
- "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz",
- "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==",
+ "version": "0.132.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz",
+ "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==",
"dev": true,
"license": "MIT",
"funding": {
@@ -2120,9 +2201,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz",
- "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==",
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
+ "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==",
"cpu": [
"arm64"
],
@@ -2137,9 +2218,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz",
- "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==",
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz",
+ "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==",
"cpu": [
"arm64"
],
@@ -2154,9 +2235,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz",
- "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==",
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz",
+ "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==",
"cpu": [
"x64"
],
@@ -2171,9 +2252,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz",
- "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==",
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz",
+ "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==",
"cpu": [
"x64"
],
@@ -2188,9 +2269,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz",
- "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==",
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz",
+ "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==",
"cpu": [
"arm"
],
@@ -2205,9 +2286,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz",
- "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==",
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz",
+ "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==",
"cpu": [
"arm64"
],
@@ -2225,9 +2306,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz",
- "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==",
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz",
+ "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==",
"cpu": [
"arm64"
],
@@ -2245,9 +2326,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz",
- "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==",
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz",
+ "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==",
"cpu": [
"ppc64"
],
@@ -2265,9 +2346,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz",
- "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==",
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz",
+ "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==",
"cpu": [
"s390x"
],
@@ -2285,9 +2366,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz",
- "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==",
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz",
+ "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==",
"cpu": [
"x64"
],
@@ -2305,9 +2386,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz",
- "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==",
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz",
+ "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==",
"cpu": [
"x64"
],
@@ -2325,9 +2406,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz",
- "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==",
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz",
+ "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==",
"cpu": [
"arm64"
],
@@ -2342,9 +2423,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz",
- "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==",
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz",
+ "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==",
"cpu": [
"wasm32"
],
@@ -2361,9 +2442,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz",
- "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==",
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz",
+ "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==",
"cpu": [
"arm64"
],
@@ -2378,9 +2459,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz",
- "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==",
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz",
+ "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==",
"cpu": [
"x64"
],
@@ -2684,14 +2765,14 @@
"license": "Python-2.0"
},
"node_modules/babel-plugin-polyfill-corejs2": {
- "version": "0.4.16",
- "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.16.tgz",
- "integrity": "sha512-xaVwwSfebXf0ooE11BJovZYKhFjIvQo7TsyVpETuIeH2JHv0k/T6Y5j22pPTvqYqmpkxdlPAJlyJ0tfOJAoMxw==",
+ "version": "0.4.15",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.15.tgz",
+ "integrity": "sha512-hR3GwrRwHUfYwGfrisXPIDP3JcYfBrW7wKE7+Au6wDYl7fm/ka1NEII6kORzxNU556JjfidZeBsO10kYvtV1aw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.28.6",
- "@babel/helper-define-polyfill-provider": "^0.6.7",
+ "@babel/helper-define-polyfill-provider": "^0.6.6",
"semver": "^6.3.1"
},
"peerDependencies": {
@@ -2699,13 +2780,13 @@
}
},
"node_modules/babel-plugin-polyfill-corejs3": {
- "version": "0.14.1",
- "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.1.tgz",
- "integrity": "sha512-ENp89vM9Pw4kv/koBb5N2f9bDZsR0hpf3BdPMOg/pkS3pwO4dzNnQZVXtBbeyAadgm865DmQG2jMMLqmZXvuCw==",
+ "version": "0.14.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.0.tgz",
+ "integrity": "sha512-AvDcMxJ34W4Wgy4KBIIePQTAOP1Ie2WFwkQp3dB7FQ/f0lI5+nM96zUnYEOE1P9sEg0es5VCP0HxiWu5fUHZAQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-define-polyfill-provider": "^0.6.7",
+ "@babel/helper-define-polyfill-provider": "^0.6.6",
"core-js-compat": "^3.48.0"
},
"peerDependencies": {
@@ -2713,13 +2794,13 @@
}
},
"node_modules/babel-plugin-polyfill-regenerator": {
- "version": "0.6.7",
- "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.7.tgz",
- "integrity": "sha512-OTYbUlSwXhNgr4g6efMZgsO8//jA61P7ZbRX3iTT53VON8l+WQS8IAUEVo4a4cWknrg2W8Cj4gQhRYNCJ8GkAA==",
+ "version": "0.6.6",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.6.tgz",
+ "integrity": "sha512-hYm+XLYRMvupxiQzrvXUj7YyvFFVfv5gI0R71AJzudg1g2AI2vyCPPIFEBjk162/wFzti3inBHo7isWFuEVS/A==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-define-polyfill-provider": "^0.6.7"
+ "@babel/helper-define-polyfill-provider": "^0.6.6"
},
"peerDependencies": {
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
@@ -2744,16 +2825,13 @@
}
},
"node_modules/baseline-browser-mapping": {
- "version": "2.10.0",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
- "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
+ "version": "2.9.19",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
+ "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
- "baseline-browser-mapping": "dist/cli.cjs"
- },
- "engines": {
- "node": ">=6.0.0"
+ "baseline-browser-mapping": "dist/cli.js"
}
},
"node_modules/brace-expansion": {
@@ -2848,9 +2926,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001777",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz",
- "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==",
+ "version": "1.0.30001769",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz",
+ "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==",
"dev": true,
"funding": [
{
@@ -3337,9 +3415,9 @@
}
},
"node_modules/electron-to-chromium": {
- "version": "1.5.307",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz",
- "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==",
+ "version": "1.5.286",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz",
+ "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==",
"dev": true,
"license": "ISC"
},
@@ -3479,9 +3557,9 @@
}
},
"node_modules/get-east-asian-width": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz",
- "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==",
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
+ "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3565,6 +3643,14 @@
"node": ">=8.0.0"
}
},
+ "node_modules/htmx-ext-alpine-morph": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/htmx-ext-alpine-morph/-/htmx-ext-alpine-morph-2.0.2.tgz",
+ "integrity": "sha512-9pZSSQd0CU0R4/4PhF2/kUbfCcQ+gcxyOMeVwy5fmzfpxOUquVuXWYMoB7EpdMeANzLJ1ceXaakEQwmDj9c9fg==",
+ "dependencies": {
+ "htmx.org": "^2.0.2"
+ }
+ },
"node_modules/htmx.org": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.10.tgz",
@@ -3900,6 +3986,9 @@
"arm64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3921,6 +4010,9 @@
"arm64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3942,6 +4034,9 @@
"x64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3963,6 +4058,9 @@
"x64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -4028,9 +4126,9 @@
}
},
"node_modules/lodash-es": {
- "version": "4.18.1",
- "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
- "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
+ "version": "4.17.23",
+ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
+ "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
"license": "MIT"
},
"node_modules/lodash.debounce": {
@@ -4063,12 +4161,12 @@
}
},
"node_modules/minimatch": {
- "version": "10.2.4",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
- "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
+ "version": "10.2.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"license": "BlueOak-1.0.0",
"dependencies": {
- "brace-expansion": "^5.0.2"
+ "brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
@@ -4152,9 +4250,9 @@
}
},
"node_modules/ngraph.graph": {
- "version": "20.1.2",
- "resolved": "https://registry.npmjs.org/ngraph.graph/-/ngraph.graph-20.1.2.tgz",
- "integrity": "sha512-W/G3GBR3Y5UxMLHTUCPP9v+pbtpzwuAEIqP5oZV+9IwgxAIEZwh+Foc60iPc1idlnK7Zxu0p3puxAyNmDvBd0Q==",
+ "version": "20.1.1",
+ "resolved": "https://registry.npmjs.org/ngraph.graph/-/ngraph.graph-20.1.1.tgz",
+ "integrity": "sha512-KNtZWYzYe7SMOuG3vvROznU+fkPmL5cGYFsWjqt+Ob1uF5xZz5EjomtsNOZEIwVuD37/zokeEqNK1ghY4/fhDg==",
"license": "BSD-3-Clause",
"dependencies": {
"ngraph.events": "^1.4.0"
@@ -4201,9 +4299,9 @@
"license": "MIT"
},
"node_modules/node-releases": {
- "version": "2.0.36",
- "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
- "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==",
+ "version": "2.0.27",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
+ "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
"dev": true,
"license": "MIT"
},
@@ -4294,9 +4392,9 @@
}
},
"node_modules/path-scurry/node_modules/lru-cache": {
- "version": "11.2.6",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
- "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
+ "version": "11.5.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz",
+ "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
@@ -4527,13 +4625,13 @@
}
},
"node_modules/rolldown": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
- "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz",
+ "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@oxc-project/types": "=0.130.0",
+ "@oxc-project/types": "=0.132.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
@@ -4543,21 +4641,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
- "@rolldown/binding-android-arm64": "1.0.1",
- "@rolldown/binding-darwin-arm64": "1.0.1",
- "@rolldown/binding-darwin-x64": "1.0.1",
- "@rolldown/binding-freebsd-x64": "1.0.1",
- "@rolldown/binding-linux-arm-gnueabihf": "1.0.1",
- "@rolldown/binding-linux-arm64-gnu": "1.0.1",
- "@rolldown/binding-linux-arm64-musl": "1.0.1",
- "@rolldown/binding-linux-ppc64-gnu": "1.0.1",
- "@rolldown/binding-linux-s390x-gnu": "1.0.1",
- "@rolldown/binding-linux-x64-gnu": "1.0.1",
- "@rolldown/binding-linux-x64-musl": "1.0.1",
- "@rolldown/binding-openharmony-arm64": "1.0.1",
- "@rolldown/binding-wasm32-wasi": "1.0.1",
- "@rolldown/binding-win32-arm64-msvc": "1.0.1",
- "@rolldown/binding-win32-x64-msvc": "1.0.1"
+ "@rolldown/binding-android-arm64": "1.0.2",
+ "@rolldown/binding-darwin-arm64": "1.0.2",
+ "@rolldown/binding-darwin-x64": "1.0.2",
+ "@rolldown/binding-freebsd-x64": "1.0.2",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.2",
+ "@rolldown/binding-linux-arm64-gnu": "1.0.2",
+ "@rolldown/binding-linux-arm64-musl": "1.0.2",
+ "@rolldown/binding-linux-ppc64-gnu": "1.0.2",
+ "@rolldown/binding-linux-s390x-gnu": "1.0.2",
+ "@rolldown/binding-linux-x64-gnu": "1.0.2",
+ "@rolldown/binding-linux-x64-musl": "1.0.2",
+ "@rolldown/binding-openharmony-arm64": "1.0.2",
+ "@rolldown/binding-wasm32-wasi": "1.0.2",
+ "@rolldown/binding-win32-arm64-msvc": "1.0.2",
+ "@rolldown/binding-win32-x64-msvc": "1.0.2"
}
},
"node_modules/rollup-plugin-visualizer": {
@@ -4750,9 +4848,9 @@
}
},
"node_modules/three-render-objects": {
- "version": "1.41.1",
- "resolved": "https://registry.npmjs.org/three-render-objects/-/three-render-objects-1.41.1.tgz",
- "integrity": "sha512-0H7l7yREPVKfO3HL7RjPQ67T0phHgnyMeEc4ww/OCEfK6jbsm7psEcrR0SGFqGDyS/pDQTPi4DyPbS/xlHRJKw==",
+ "version": "1.42.0",
+ "resolved": "https://registry.npmjs.org/three-render-objects/-/three-render-objects-1.42.0.tgz",
+ "integrity": "sha512-KYfkPrYGEbIK8ChFocWqOF1aAN80FBUBWVYB8mB2oBpVuVN+52FvvngVYB5ieFANQu7Rt21rPYZ/xKaAgVWWRQ==",
"license": "MIT",
"dependencies": {
"@tweenjs/tween.js": "18 - 25",
@@ -4943,16 +5041,16 @@
}
},
"node_modules/vite": {
- "version": "8.0.13",
- "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
- "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
+ "version": "8.0.14",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz",
+ "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
- "postcss": "^8.5.14",
- "rolldown": "1.0.1",
+ "postcss": "^8.5.15",
+ "rolldown": "1.0.2",
"tinyglobby": "^0.2.16"
},
"bin": {
diff --git a/package.json b/package.json
index 52faf51b1..50d11a6a1 100644
--- a/package.json
+++ b/package.json
@@ -21,7 +21,8 @@
"#core:*": "./core/static/bundled/*",
"#pedagogy:*": "./pedagogy/static/bundled/*",
"#counter:*": "./counter/static/bundled/*",
- "#com:*": "./com/static/bundled/*"
+ "#com:*": "./com/static/bundled/*",
+ "#reservation:*": "./reservation/static/bundled/*"
},
"devDependencies": {
"@babel/core": "^7.29.0",
@@ -38,6 +39,7 @@
"vite": "^8.0.13"
},
"dependencies": {
+ "@alpinejs/morph": "^3.14.9",
"@alpinejs/sort": "^3.15.12",
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
"@floating-ui/dom": "^1.7.6",
@@ -45,7 +47,10 @@
"@fullcalendar/core": "^6.1.20",
"@fullcalendar/daygrid": "^6.1.20",
"@fullcalendar/icalendar": "^6.1.20",
+ "@fullcalendar/interaction": "^6.1.20",
"@fullcalendar/list": "^6.1.20",
+ "@fullcalendar/resource": "^6.1.20",
+ "@fullcalendar/resource-timeline": "^6.1.20",
"@sentry/browser": "^10.53.1",
"@zip.js/zip.js": "^2.8.26",
"3d-force-graph": "^1.80.0",
@@ -59,6 +64,7 @@
"easymde": "^2.21.0",
"glob": "^13.0.6",
"html2canvas": "^1.4.1",
+ "htmx-ext-alpine-morph": "^2.0.1",
"htmx.org": "^2.0.10",
"js-cookie": "^3.0.7",
"lit-html": "^3.3.3",
diff --git a/reservation/__init__.py b/reservation/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/reservation/admin.py b/reservation/admin.py
new file mode 100644
index 000000000..8ca8c690b
--- /dev/null
+++ b/reservation/admin.py
@@ -0,0 +1,19 @@
+from django.contrib import admin
+
+from reservation.models import ReservationSlot, Room
+
+
+@admin.register(Room)
+class RoomAdmin(admin.ModelAdmin):
+ list_display = ("name", "club")
+ list_filter = (("club", admin.RelatedOnlyFieldListFilter), "location")
+ autocomplete_fields = ("club",)
+ search_fields = ("name",)
+
+
+@admin.register(ReservationSlot)
+class ReservationSlotAdmin(admin.ModelAdmin):
+ list_display = ("room", "start_at", "end_at", "author")
+ autocomplete_fields = ("author",)
+ list_filter = ("room",)
+ date_hierarchy = "start_at"
diff --git a/reservation/api.py b/reservation/api.py
new file mode 100644
index 000000000..cd39be33f
--- /dev/null
+++ b/reservation/api.py
@@ -0,0 +1,64 @@
+from typing import Any, Literal
+
+from django.core.exceptions import ValidationError
+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 api.permissions import HasPerm
+from reservation.models import ReservationSlot, Room
+from reservation.schemas import (
+ RoomFilterSchema,
+ RoomSchema,
+ SlotFilterSchema,
+ SlotSchema,
+ UpdateReservationSlotSchema,
+)
+
+
+@api_controller("/reservation/room")
+class ReservableRoomController(ControllerBase):
+ @route.get(
+ "",
+ response=list[RoomSchema],
+ permissions=[HasPerm("reservation.view_room")],
+ url_name="fetch_reservable_rooms",
+ )
+ def fetch_rooms(self, filters: Query[RoomFilterSchema]):
+ return filters.filter(Room.objects.select_related("club"))
+
+
+@api_controller("/reservation/slot")
+class ReservationSlotController(ControllerBase):
+ @route.get(
+ "",
+ response=PaginatedResponseSchema[SlotSchema],
+ permissions=[HasPerm("reservation.view_reservationslot")],
+ url_name="fetch_reservation_slots",
+ )
+ @paginate(PageNumberPaginationExtra)
+ def fetch_slots(self, filters: Query[SlotFilterSchema]):
+ return filters.filter(
+ ReservationSlot.objects.select_related("author").order_by("start_at")
+ )
+
+ @route.patch(
+ "/reservation/slot/{int:slot_id}",
+ permissions=[HasPerm("reservation.change_reservationslot")],
+ response={
+ 200: None,
+ 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, slot_id: int, params: UpdateReservationSlotSchema):
+ slot = self.get_object_or_exception(ReservationSlot, id=slot_id)
+ slot.start_at = params.start_at
+ slot.end_at = params.end_at
+ try:
+ slot.full_clean()
+ slot.save()
+ except ValidationError as e:
+ return self.create_response({"detail": dict(e)}, status_code=409)
diff --git a/reservation/apps.py b/reservation/apps.py
new file mode 100644
index 000000000..058a964a6
--- /dev/null
+++ b/reservation/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class ReservationConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "reservation"
diff --git a/reservation/forms.py b/reservation/forms.py
new file mode 100644
index 000000000..90b0d8919
--- /dev/null
+++ b/reservation/forms.py
@@ -0,0 +1,60 @@
+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
+from core.views.forms import FutureDateTimeField, SelectDateTime
+from reservation.models import ReservationSlot, Room
+
+
+class RoomCreateForm(forms.ModelForm):
+ required_css_class = "required"
+ error_css_class = "error"
+
+ class Meta:
+ model = Room
+ fields = ["name", "club", "location", "description"]
+ widgets = {"club": AutoCompleteSelectClub}
+
+
+class RoomUpdateForm(forms.ModelForm):
+ required_css_class = "required"
+ error_css_class = "error"
+
+ class Meta:
+ model = Room
+ fields = ["name", "club", "location", "description"]
+ widgets = {"club": AutoCompleteSelectClub}
+
+ def __init__(self, *args, request_user: User, **kwargs):
+ super().__init__(*args, **kwargs)
+ if not request_user.has_perm("reservation.change_room"):
+ # if the user doesn't have the global edition permission
+ # (i.e. it's a club board member, but not a sith admin)
+ # some fields aren't editable
+ del self.fields["club"]
+
+
+class ReservationForm(forms.ModelForm):
+ required_css_class = "required"
+ error_css_class = "error"
+
+ class Meta:
+ model = ReservationSlot
+ 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)
+ self.author = author
+
+ def save(self, commit: bool = True): # noqa FBT001
+ self.instance.author = self.author
+ return super().save(commit)
diff --git a/reservation/migrations/0001_initial.py b/reservation/migrations/0001_initial.py
new file mode 100644
index 000000000..7ecac7fc7
--- /dev/null
+++ b/reservation/migrations/0001_initial.py
@@ -0,0 +1,117 @@
+# Generated by Django 5.2.1 on 2025-06-05 10:44
+
+import django.core.validators
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ initial = True
+
+ dependencies = [
+ ("club", "0014_alter_club_options_rename_unix_name_club_slug_name_and_more"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Room",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("name", models.CharField(max_length=100, verbose_name="room name")),
+ (
+ "description",
+ models.TextField(
+ blank=True, default="", verbose_name="description"
+ ),
+ ),
+ (
+ "location",
+ models.CharField(
+ blank=True,
+ choices=[
+ ("BELFORT", "Belfort"),
+ ("SEVENANS", "Sévenans"),
+ ("MONTBELIARD", "Montbéliard"),
+ ],
+ verbose_name="site",
+ ),
+ ),
+ (
+ "club",
+ models.ForeignKey(
+ help_text="The club which manages this room",
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="reservable_rooms",
+ to="club.club",
+ verbose_name="room owner",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "reservable room",
+ "verbose_name_plural": "reservable rooms",
+ },
+ ),
+ migrations.CreateModel(
+ name="ReservationSlot",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "comment",
+ models.TextField(blank=True, default="", verbose_name="comment"),
+ ),
+ (
+ "start_at",
+ models.DateTimeField(db_index=True, verbose_name="slot start"),
+ ),
+ ("end_at", models.DateTimeField(verbose_name="slot end")),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ (
+ "author",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="author",
+ ),
+ ),
+ (
+ "room",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="slots",
+ to="reservation.room",
+ verbose_name="reserved room",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "reservation slot",
+ "verbose_name_plural": "reservation slots",
+ "constraints": [
+ 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/migrations/__init__.py b/reservation/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/reservation/models.py b/reservation/models.py
new file mode 100644
index 000000000..534beefca
--- /dev/null
+++ b/reservation/models.py
@@ -0,0 +1,100 @@
+from __future__ import annotations
+
+from typing import Self
+
+from django.core.exceptions import ValidationError
+from django.db import models
+from django.db.models import F, Q
+from django.utils.translation import gettext_lazy as _
+
+from club.models import Club
+from core.models import User
+
+
+class Room(models.Model):
+ name = models.CharField(_("room name"), max_length=100)
+ description = models.TextField(_("description"), blank=True, default="")
+ club = models.ForeignKey(
+ Club,
+ on_delete=models.CASCADE,
+ related_name="reservable_rooms",
+ verbose_name=_("room owner"),
+ help_text=_("The club which manages this room"),
+ )
+ location = models.CharField(
+ _("site"),
+ blank=True,
+ choices=[
+ ("BELFORT", "Belfort"),
+ ("SEVENANS", "Sévenans"),
+ ("MONTBELIARD", "Montbéliard"),
+ ],
+ )
+
+ class Meta:
+ verbose_name = _("reservable room")
+ verbose_name_plural = _("reservable rooms")
+
+ def __str__(self):
+ return self.name
+
+ 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
+ return user.has_perm("reservation.change_room") or self.club.board_group_id in [
+ g.id for g in user.cached_groups
+ ]
+
+
+class ReservationSlotQuerySet(models.QuerySet):
+ def overlapping_with(self, slot: ReservationSlot) -> Self:
+ return self.filter(
+ 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)
+ )
+
+
+class ReservationSlot(models.Model):
+ room = models.ForeignKey(
+ Room,
+ on_delete=models.CASCADE,
+ related_name="slots",
+ verbose_name=_("reserved room"),
+ )
+ 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(_("slot end"))
+ created_at = models.DateTimeField(auto_now_add=True)
+
+ objects = ReservationSlotQuerySet.as_manager()
+
+ class Meta:
+ verbose_name = _("reservation slot")
+ verbose_name_plural = _("reservation slots")
+ constraints = [
+ models.CheckConstraint(
+ condition=Q(end_at__gt=F("start_at")),
+ name="reservation_slot_end_after_start",
+ violation_error_code="start_after_end",
+ )
+ ]
+
+ def __str__(self):
+ return f"{self.room.name} : {self.start_at} - {self.end_at}"
+
+ def clean(self):
+ super().clean()
+ if self.end_at is None or self.start_at is None:
+ # if there is no start or no end, then there is no
+ # point to check if this perm overlap with another,
+ # so in this case, don't do the overlap check and let
+ # Django manage the non-null constraint error.
+ return
+ 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."))
diff --git a/reservation/schemas.py b/reservation/schemas.py
new file mode 100644
index 000000000..b97bc509c
--- /dev/null
+++ b/reservation/schemas.py
@@ -0,0 +1,46 @@
+from datetime import datetime
+
+from ninja import FilterSchema, ModelSchema, Schema
+from pydantic import Field, FutureDatetime
+
+from club.schemas import SimpleClubSchema
+from core.schemas import SimpleUserSchema
+from reservation.models import ReservationSlot, Room
+
+
+class RoomFilterSchema(FilterSchema):
+ club: set[int] | None = Field(None, q="club_id__in")
+
+
+class RoomSchema(ModelSchema):
+ class Meta:
+ model = Room
+ fields = ["id", "name", "description", "location"]
+
+ club: SimpleClubSchema
+
+ @staticmethod
+ def resolve_location(obj: Room):
+ return obj.get_location_display()
+
+
+class SlotFilterSchema(FilterSchema):
+ after: datetime = Field(default=None, q="end_at__gt")
+ before: datetime = Field(default=None, q="start_at__lt")
+ room: set[int] | None = None
+ club: set[int] | None = None
+
+
+class SlotSchema(ModelSchema):
+ class Meta:
+ model = ReservationSlot
+ fields = ["id", "room", "comment"]
+
+ start: datetime = Field(alias="start_at")
+ end: datetime = Field(alias="end_at")
+ author: SimpleUserSchema
+
+
+class UpdateReservationSlotSchema(Schema):
+ start_at: FutureDatetime
+ end_at: FutureDatetime
diff --git a/reservation/static/bundled/reservation/components/room-scheduler-index.ts b/reservation/static/bundled/reservation/components/room-scheduler-index.ts
new file mode 100644
index 000000000..9f0eb63c4
--- /dev/null
+++ b/reservation/static/bundled/reservation/components/room-scheduler-index.ts
@@ -0,0 +1,136 @@
+import {
+ Calendar,
+ type DateSelectArg,
+ type EventDropArg,
+ type EventSourceFuncArg,
+} from "@fullcalendar/core";
+import enLocale from "@fullcalendar/core/locales/en-gb";
+import frLocale from "@fullcalendar/core/locales/fr";
+import interactionPlugin, { type EventResizeDoneArg } from "@fullcalendar/interaction";
+import resourceTimelinePlugin from "@fullcalendar/resource-timeline";
+import { paginated } from "#core:utils/api";
+import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
+import {
+ type ReservationslotFetchSlotsData,
+ reservableroomFetchRooms,
+ reservationslotFetchSlots,
+ reservationslotUpdateSlot,
+ type SlotSchema,
+} from "#openapi";
+import type { SlotSelectedEventArg } from "#reservation:reservation/types";
+
+@registerComponent("room-scheduler")
+export class RoomScheduler extends inheritHtmlElement("div") {
+ static observedAttributes = ["locale", "can_edit_slot", "can_create_slot"];
+ private scheduler: Calendar;
+ private locale = "en";
+ private canEditSlot = false;
+ private canBookSlot = false;
+ private canDeleteSlot = false;
+
+ attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) {
+ if (name === "locale") {
+ this.locale = newValue;
+ }
+ if (name === "can_edit_slot") {
+ this.canEditSlot = newValue.toLowerCase() === "true";
+ }
+ if (name === "can_create_slot") {
+ this.canBookSlot = newValue.toLowerCase() === "true";
+ }
+ if (name === "can_delete_slot") {
+ this.canDeleteSlot = newValue.toLowerCase() === "true";
+ }
+ }
+
+ /**
+ * Fetch the events displayed in the timeline.
+ * cf https://fullcalendar.io/docs/events-function
+ */
+ async fetchEvents(fetchInfo: EventSourceFuncArg) {
+ const res: SlotSchema[] = await paginated(reservationslotFetchSlots, {
+ query: { after: fetchInfo.startStr, before: fetchInfo.endStr },
+ } as ReservationslotFetchSlotsData);
+ return res.map((i) =>
+ Object.assign(i, {
+ title: `${i.author.first_name} ${i.author.last_name}`,
+ resourceId: i.room,
+ editable: new Date(i.start) > new Date(),
+ }),
+ );
+ }
+
+ /**
+ * Fetch the resources which events are associated with.
+ * cf https://fullcalendar.io/docs/resources-function
+ */
+ async fetchResources() {
+ const res = await reservableroomFetchRooms();
+ return res.data.map((i) => Object.assign(i, { title: i.name, group: i.location }));
+ }
+
+ /**
+ * Send a request to the API to change
+ * the start and the duration of a reservation slot
+ */
+ async changeReservation(args: EventDropArg | EventResizeDoneArg) {
+ const response = await reservationslotUpdateSlot({
+ // biome-ignore lint/style/useNamingConvention: api is snake_case
+ path: { slot_id: Number.parseInt(args.event.id) },
+ // biome-ignore lint/style/useNamingConvention: api is snake_case
+ body: { start_at: args.event.startStr, end_at: args.event.endStr },
+ });
+ if (response.response.ok) {
+ document.dispatchEvent(new CustomEvent("reservationSlotChanged"));
+ this.scheduler.refetchEvents();
+ }
+ }
+
+ selectFreeSlot(infos: DateSelectArg) {
+ document.dispatchEvent(
+ new CustomEvent("timeSlotSelected", {
+ detail: {
+ ressource: Number.parseInt(infos.resource.id),
+ start: infos.startStr,
+ end: infos.endStr,
+ },
+ }),
+ );
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.scheduler = new Calendar(this.node, {
+ schedulerLicenseKey: "GPL-My-Project-Is-Open-Source",
+ initialView: "resourceTimelineDay",
+ headerToolbar: {
+ left: "prev,next today",
+ center: "title",
+ right: "resourceTimelineDay,resourceTimelineWeek",
+ },
+ plugins: [resourceTimelinePlugin, interactionPlugin],
+ locales: [frLocale, enLocale],
+ height: "auto",
+ locale: this.locale,
+ resourceGroupField: "group",
+ resourceAreaHeaderContent: gettext("Rooms"),
+ editable: this.canEditSlot,
+ snapDuration: "00:15",
+ eventConstraint: { start: new Date() }, // forbid edition of past events
+ eventOverlap: false,
+ eventResourceEditable: false,
+ refetchResourcesOnNavigate: true,
+ resourceAreaWidth: "20%",
+ resources: this.fetchResources,
+ events: this.fetchEvents,
+ select: this.selectFreeSlot,
+ selectOverlap: false,
+ selectable: this.canBookSlot,
+ selectConstraint: { start: new Date() },
+ nowIndicator: true,
+ eventDrop: this.changeReservation,
+ eventResize: this.changeReservation,
+ });
+ this.scheduler.render();
+ }
+}
diff --git a/reservation/static/bundled/reservation/slot-reservation-index.ts b/reservation/static/bundled/reservation/slot-reservation-index.ts
new file mode 100644
index 000000000..2bde9c2a1
--- /dev/null
+++ b/reservation/static/bundled/reservation/slot-reservation-index.ts
@@ -0,0 +1,39 @@
+import { AlertMessage } from "#core:utils/alert-message";
+import type { SlotSelectedEventArg } from "#reservation:reservation/types";
+
+document.addEventListener("alpine:init", () => {
+ Alpine.data("slotReservation", () => ({
+ start: null as string,
+ end: null as string,
+ room: null as number,
+ showForm: false,
+
+ init() {
+ document.addEventListener(
+ "timeSlotSelected",
+ (event: CustomEvent) => {
+ this.start = event.detail.start.split("+")[0];
+ this.end = event.detail.end.split("+")[0];
+ this.room = event.detail.ressource;
+ this.showForm = true;
+ this.$nextTick(() => this.$el.scrollIntoView({ behavior: "smooth" })).then();
+ },
+ );
+ },
+ }));
+
+ /**
+ * Component that will catch events sent from the scheduler
+ * to display success messages accordingly.
+ */
+ Alpine.data("scheduleMessages", () => ({
+ alertMessage: new AlertMessage({ defaultDuration: 2000 }),
+ init() {
+ document.addEventListener("reservationSlotChanged", (_event: CustomEvent) => {
+ this.alertMessage.display(gettext("This slot has been successfully moved"), {
+ success: true,
+ });
+ });
+ },
+ }));
+});
diff --git a/reservation/static/bundled/reservation/types.d.ts b/reservation/static/bundled/reservation/types.d.ts
new file mode 100644
index 000000000..8e6e3e333
--- /dev/null
+++ b/reservation/static/bundled/reservation/types.d.ts
@@ -0,0 +1,5 @@
+export interface SlotSelectedEventArg {
+ start: string;
+ end: string;
+ ressource: number;
+}
diff --git a/reservation/static/reservation/reservation.scss b/reservation/static/reservation/reservation.scss
new file mode 100644
index 000000000..b553fd986
--- /dev/null
+++ b/reservation/static/reservation/reservation.scss
@@ -0,0 +1,39 @@
+#slot-reservation {
+ margin-top: 3em;
+
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+
+ h3 {
+ display: block;
+ margin: auto;
+ text-align: left;
+ }
+
+ .alert, .error {
+ display: block;
+ margin: 1em auto auto;
+ max-width: 400px;
+ word-wrap: break-word;
+ text-wrap: wrap;
+ }
+
+ form {
+ display: flex;
+ flex-direction: column;
+ gap: .5em;
+ justify-content: center;
+
+ .buttons-row {
+ input[type="submit"], button {
+ margin: 0;
+ }
+ }
+ textarea {
+ max-width: unset;
+ width: 100%;
+ margin-top: unset;
+ }
+ }
+}
\ No newline at end of file
diff --git a/reservation/templates/reservation/fragments/create_reservation.jinja b/reservation/templates/reservation/fragments/create_reservation.jinja
new file mode 100644
index 000000000..4ba2f17dc
--- /dev/null
+++ b/reservation/templates/reservation/fragments/create_reservation.jinja
@@ -0,0 +1,51 @@
+
+ {% trans %}Book a room{% endtrans %}
+ {% set non_field_errors = form.non_field_errors() %}
+ {% if non_field_errors %}
+
+ {% for error in non_field_errors %}
+ {{ error }}
+ {% endfor %}
+
+ {% endif %}
+
+
diff --git a/reservation/templates/reservation/macros.jinja b/reservation/templates/reservation/macros.jinja
new file mode 100644
index 000000000..fb5ae46d4
--- /dev/null
+++ b/reservation/templates/reservation/macros.jinja
@@ -0,0 +1,27 @@
+{% macro room_detail(room, can_edit, can_delete) %}
+
+
+
{{ room.name }}
+
{{ room.get_location_display() }}
+
{{ room.description|truncate(250) }}
+
+
+ {% if can_edit %}
+
+
+
+ {% endif %}
+ {% if can_delete %}
+
+
+
+ {% endif %}
+
+
+{% endmacro %}
\ No newline at end of file
diff --git a/reservation/templates/reservation/schedule.jinja b/reservation/templates/reservation/schedule.jinja
new file mode 100644
index 000000000..9e3233cfd
--- /dev/null
+++ b/reservation/templates/reservation/schedule.jinja
@@ -0,0 +1,33 @@
+{% extends "core/base.jinja" %}
+
+{% block additional_js %}
+
+
+{% endblock %}
+
+{% block additional_css %}
+
+
+{% endblock %}
+
+
+{% block content %}
+ {% trans %}Room reservation{% endtrans %}
+
+
+ {% if user.has_perm("reservation.add_reservationslot") %}
+ {% trans %}You can book a room by selecting a free slot in the calendar.{% endtrans %}
+ {{ add_slot_fragment }}
+ {% endif %}
+{% endblock %}
\ No newline at end of file
diff --git a/reservation/tests/__init__.py b/reservation/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/reservation/tests/test_room.py b/reservation/tests/test_room.py
new file mode 100644
index 000000000..36ccd2075
--- /dev/null
+++ b/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
diff --git a/reservation/tests/test_slot.py b/reservation/tests/test_slot.py
new file mode 100644
index 000000000..70ffe5db1
--- /dev/null
+++ b/reservation/tests/test_slot.py
@@ -0,0 +1,207 @@
+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):
+ 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)
+ 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 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):
+ 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."]
+ }
+
+
+@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
diff --git a/reservation/urls.py b/reservation/urls.py
new file mode 100644
index 000000000..908415b82
--- /dev/null
+++ b/reservation/urls.py
@@ -0,0 +1,19 @@
+from django.urls import path
+
+from reservation.views import (
+ ReservationFragment,
+ ReservationScheduleView,
+ RoomCreateView,
+ RoomDeleteView,
+ RoomUpdateView,
+)
+
+urlpatterns = [
+ path("", ReservationScheduleView.as_view(), name="main"),
+ path("room/create/", RoomCreateView.as_view(), name="room_create"),
+ path("room//edit", RoomUpdateView.as_view(), name="room_edit"),
+ path("room//delete", RoomDeleteView.as_view(), name="room_delete"),
+ path(
+ "fragment/reservation", ReservationFragment.as_view(), name="make_reservation"
+ ),
+]
diff --git a/reservation/views.py b/reservation/views.py
new file mode 100644
index 000000000..563345efd
--- /dev/null
+++ b/reservation/views.py
@@ -0,0 +1,72 @@
+# Create your views here.
+
+from django.contrib.auth.mixins import PermissionRequiredMixin
+from django.contrib.messages.views import SuccessMessageMixin
+from django.urls import reverse, reverse_lazy
+from django.utils.translation import gettext_lazy as _
+from django.views.generic import CreateView, DeleteView, TemplateView, UpdateView
+
+from club.models import Club
+from core.auth.mixins import CanEditMixin
+from core.views import UseFragmentsMixin
+from core.views.mixins import FragmentMixin
+from reservation.forms import ReservationForm, RoomCreateForm, RoomUpdateForm
+from reservation.models import ReservationSlot, Room
+
+
+class ReservationFragment(PermissionRequiredMixin, FragmentMixin, CreateView):
+ model = ReservationSlot
+ form_class = ReservationForm
+ permission_required = "reservation.add_reservationslot"
+ template_name = "reservation/fragments/create_reservation.jinja"
+ success_url = reverse_lazy("reservation:main")
+ reload_on_redirect = True
+ object = None
+
+ def get_form_kwargs(self):
+ return super().get_form_kwargs() | {"author": self.request.user}
+
+
+class ReservationScheduleView(PermissionRequiredMixin, UseFragmentsMixin, TemplateView):
+ template_name = "reservation/schedule.jinja"
+ permission_required = "reservation.view_reservationslot"
+ fragments = {"add_slot_fragment": ReservationFragment}
+
+
+class RoomCreateView(PermissionRequiredMixin, CreateView):
+ form_class = RoomCreateForm
+ template_name = "core/create.jinja"
+ permission_required = "reservation.add_room"
+
+ def get_initial(self):
+ init = super().get_initial()
+ if "club" in self.request.GET:
+ club_id = self.request.GET["club"]
+ if club_id.isdigit() and int(club_id) > 0:
+ 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
+ pk_url_kwarg = "room_id"
+ form_class = RoomUpdateForm
+ template_name = "core/edit.jinja"
+ success_message = _("%(name)s was updated successfully")
+
+ def get_form_kwargs(self):
+ return super().get_form_kwargs() | {"request_user": self.request.user}
+
+ def get_success_url(self):
+ return self.request.path
+
+
+class RoomDeleteView(PermissionRequiredMixin, DeleteView):
+ model = Room
+ pk_url_kwarg = "room_id"
+ template_name = "core/delete_confirm.jinja"
+ success_url = reverse_lazy("reservation:room_list")
+ permission_required = "reservation.delete_room"
diff --git a/sith/settings.py b/sith/settings.py
index 9968055ff..ab6e69eb3 100644
--- a/sith/settings.py
+++ b/sith/settings.py
@@ -128,6 +128,7 @@ def optional_file_parser(value: str) -> Path | None:
"trombi",
"matmat",
"pedagogy",
+ "reservation",
"galaxy",
"antispam",
"timetable",
@@ -279,7 +280,7 @@ def optional_file_parser(value: str) -> Path | None:
# Internationalization
# https://docs.djangoproject.com/en/1.8/topics/i18n/
-LANGUAGE_CODE = "fr-FR"
+LANGUAGE_CODE = "fr"
LANGUAGES = [("en", _("English")), ("fr", _("French"))]
diff --git a/sith/urls.py b/sith/urls.py
index e66293735..89d7c6fe6 100644
--- a/sith/urls.py
+++ b/sith/urls.py
@@ -49,6 +49,10 @@
path("trombi/", include(("trombi.urls", "trombi"), namespace="trombi")),
path("matmatronch/", include(("matmat.urls", "matmat"), namespace="matmat")),
path("pedagogy/", include(("pedagogy.urls", "pedagogy"), namespace="pedagogy")),
+ path(
+ "reservation/",
+ include(("reservation.urls", "reservation"), namespace="reservation"),
+ ),
path("admin/", admin.site.urls),
path("i18n/", include("django.conf.urls.i18n")),
path("jsi18n/", JavaScriptCatalog.as_view(), name="javascript-catalog"),
diff --git a/tsconfig.json b/tsconfig.json
index 25b3cd17f..f3e49d47e 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -18,7 +18,8 @@
"#core:*": ["./core/static/bundled/*"],
"#pedagogy:*": ["./pedagogy/static/bundled/*"],
"#counter:*": ["./counter/static/bundled/*"],
- "#com:*": ["./com/static/bundled/*"]
+ "#com:*": ["./com/static/bundled/*"],
+ "#reservation:*": ["./reservation/static/bundled/*"]
}
}
}