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 %}Club tools{% endtrans %}

+

{% trans %}Club tools{% endtrans %} ({{ club.name }})

{% trans %}Communication:{% endtrans %}

+

{% trans %}Reservable rooms{% endtrans %}

+ + {% trans %}Add a room{% endtrans %} + + {%- if reservable_rooms|length > 0 -%} + + {%- else -%} +

+ {% trans %}This club manages no reservable room{% endtrans %} +

+ {%- endif -%}

{% trans %}Counters:{% endtrans %}

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 %} +
    + {% csrf_token %} +
    + {{ form.room.errors }} + {{ form.room.label_tag() }} + {{ form.room|add_attr("x-model=room") }} +
    +
    + {{ form.start_at.errors }} + {{ form.start_at.label_tag() }} + {{ form.start_at|add_attr("x-model=start") }} +
    +
    + {{ form.end_at.errors }} + {{ form.end_at.label_tag() }} + {{ form.end_at|add_attr("x-model=end") }} +
    +
    + {{ form.comment.errors }} + {{ form.comment.label_tag() }} + {{ form.comment }} +
    +
    + + +
    +
    +
    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/*"] } } }