From 691d956c0e74d693ef308f9ef80add64c23ebc33 Mon Sep 17 00:00:00 2001 From: Thomas Girod Date: Sun, 13 Apr 2025 15:09:42 +0200 Subject: [PATCH] create reservation models --- core/management/commands/populate.py | 11 +- reservation/__init__.py | 0 reservation/admin.py | 19 ++++ reservation/apps.py | 6 ++ reservation/migrations/0001_initial.py | 141 +++++++++++++++++++++++++ reservation/migrations/__init__.py | 0 reservation/models.py | 107 +++++++++++++++++++ reservation/tests.py | 1 + reservation/views.py | 1 + sith/settings.py | 1 + 10 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 reservation/__init__.py create mode 100644 reservation/admin.py create mode 100644 reservation/apps.py create mode 100644 reservation/migrations/0001_initial.py create mode 100644 reservation/migrations/__init__.py create mode 100644 reservation/models.py create mode 100644 reservation/tests.py create mode 100644 reservation/views.py diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py index 96d322f2..7d2f4262 100644 --- a/core/management/commands/populate.py +++ b/core/management/commands/populate.py @@ -812,7 +812,16 @@ Welcome to the wiki page! subscribers = Group.objects.create(name="Subscribers") subscribers.permissions.add( - *list(perms.filter(codename__in=["add_news", "add_uvcomment"])) + *list( + perms.filter( + codename__in=[ + "add_news", + "add_uvcomment", + "add_reservationslot", + "view_reservationslot", + ] + ) + ) ) old_subscribers = Group.objects.create(name="Old subscribers") old_subscribers.permissions.add( diff --git a/reservation/__init__.py b/reservation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/reservation/admin.py b/reservation/admin.py new file mode 100644 index 00000000..ee026370 --- /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),) + autocomplete_fields = ("club",) + search_fields = ("name",) + + +@admin.register(ReservationSlot) +class ReservationSlotAdmin(admin.ModelAdmin): + list_display = ("room", "start_at", "duration", "author") + autocomplete_fields = ("author",) + list_filter = ("room",) + date_hierarchy = "start_at" diff --git a/reservation/apps.py b/reservation/apps.py new file mode 100644 index 00000000..058a964a --- /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/migrations/0001_initial.py b/reservation/migrations/0001_initial.py new file mode 100644 index 00000000..3c9077aa --- /dev/null +++ b/reservation/migrations/0001_initial.py @@ -0,0 +1,141 @@ +# Generated by Django 5.2 on 2025-04-21 14:19 + +import django.core.validators +import django.db.models.deletion +import django.db.models.expressions +from django.conf import settings +from django.db import migrations, models + +import core.fields + + +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" + ), + ), + ( + "logo", + core.fields.ResizedImageField( + blank=True, + force_format="WEBP", + height=100, + upload_to="rooms", + verbose_name="logo", + width=100, + ), + ), + ("address", models.CharField(max_length=255, verbose_name="address")), + ( + "club", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="reservable_rooms", + to="club.club", + verbose_name="room owner", + ), + ), + ( + "occupancy", + models.PositiveSmallIntegerField( + default=1, + help_text="The maximum number of people this room can host at once", + validators=[django.core.validators.MinValueValidator(1)], + verbose_name="maximum occupancy", + ), + ), + ], + 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", + ), + ), + ( + "nb_people", + models.PositiveSmallIntegerField( + default=1, + help_text="How many people will attend this reservation slot", + validators=[django.core.validators.MinValueValidator(1)], + verbose_name="number of people", + ), + ), + ( + "comment", + models.TextField(blank=True, default="", verbose_name="comment"), + ), + ( + "start_at", + models.DateTimeField(db_index=True, verbose_name="slot start"), + ), + ("duration", models.DurationField(verbose_name="duration")), + ( + "end_at", + models.GeneratedField( + db_persist=False, + expression=django.db.models.expressions.CombinedExpression( + models.F("start_at"), "+", models.F("duration") + ), + output_field=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", + }, + ), + ] diff --git a/reservation/migrations/__init__.py b/reservation/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/reservation/models.py b/reservation/models.py new file mode 100644 index 00000000..fa682a9c --- /dev/null +++ b/reservation/models.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from typing import Self + +from django.core.exceptions import ValidationError +from django.core.validators import MinValueValidator +from django.db import models +from django.db.models import F +from django.db.models import F, Q +from django.utils.translation import gettext_lazy as _ + +from club.models import Club +from core.fields import ResizedImageField +from core.models import User + + +class Room(models.Model): + name = models.CharField(_("room name"), max_length=100) + description = models.TextField(_("description"), blank=True, default="") + logo = ResizedImageField( + width=100, + height=100, + force_format="WEBP", + upload_to="rooms", + verbose_name=_("logo"), + blank=True, + ) + club = models.ForeignKey( + Club, + on_delete=models.CASCADE, + related_name="reservable_rooms", + verbose_name=_("room owner"), + ) + address = models.CharField(_("address"), max_length=255) + occupancy = models.PositiveSmallIntegerField( + verbose_name=_("maximum occupancy"), + help_text=_("The maximum number of people this room can host at once"), + validators=[MinValueValidator(1)], + ) + + class Meta: + verbose_name = _("reservable room") + verbose_name_plural = _("reservable rooms") + + def __str__(self): + return self.name + + + +class ReservationSlotQuerySet(models.QuerySet): + def overlapping_with(self, other: ReservationSlot) -> Self: + return self.filter( + Q(end_at__gt=other.start_at, end_ad__lt=other.end_at) + | Q(start_at__lt=other.start_at, start_ad__gt=other.start_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")) + nb_people = models.PositiveSmallIntegerField( + verbose_name=_("number of people"), + help_text=_("How many people will attend this reservation slot"), + default=1, + validators=[MinValueValidator(1)], + ) + comment = models.TextField(_("comment"), blank=True, default="") + start_at = models.DateTimeField(_("slot start"), db_index=True) + duration = models.DurationField(_("duration")) + end_at = models.GeneratedField( + verbose_name=_("slot end"), + expression=F("start_at") + F("duration"), + output_field=models.DateTimeField(), + db_persist=False, + ) + created_at = models.DateTimeField(auto_now_add=True) + + objects = ReservationSlotQuerySet.as_manager() + + class Meta: + verbose_name = _("reservation slot") + verbose_name_plural = _("reservation slots") + + def __str__(self): + return f"{self.room.name} : {self.start_at} - {self.end_at}" + + def clean(self): + super().clean() + if self.nb_people > self.room.occupancy: + raise ValidationError( + _( + "You declared an attendance of %(nb_people)d, " + "but this room can only host %(occupancy)d people." + ) + % {"nb_people": self.nb_people, "occupancy": self.room.occupancy} + ) + if ( + ReservationSlot.objects.overlapping_with(self) + .filter(room_id=self.room_id) + .exists() + ): + raise ValidationError(_("There is already a reservation on this slot.")) diff --git a/reservation/tests.py b/reservation/tests.py new file mode 100644 index 00000000..a39b155a --- /dev/null +++ b/reservation/tests.py @@ -0,0 +1 @@ +# Create your tests here. diff --git a/reservation/views.py b/reservation/views.py new file mode 100644 index 00000000..60f00ef0 --- /dev/null +++ b/reservation/views.py @@ -0,0 +1 @@ +# Create your views here. diff --git a/sith/settings.py b/sith/settings.py index 5ccbd136..970cff02 100644 --- a/sith/settings.py +++ b/sith/settings.py @@ -121,6 +121,7 @@ INSTALLED_APPS = ( "trombi", "matmat", "pedagogy", + "reservation", "galaxy", "antispam", )