From d0bf77d1025e687dced7954913ceb77cf7e2d007 Mon Sep 17 00:00:00 2001
From: Thomas Girod <thgirod@hotmail.com>
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 | 144 +++++++++++++++++++++++++
 reservation/migrations/__init__.py     |   0
 reservation/models.py                  | 101 +++++++++++++++++
 reservation/tests.py                   |   1 +
 reservation/views.py                   |   1 +
 sith/settings.py                       |   1 +
 10 files changed, 283 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 ea1f0342..20ee188e 100644
--- a/core/management/commands/populate.py
+++ b/core/management/commands/populate.py
@@ -788,7 +788,16 @@ class Command(BaseCommand):
 
         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..3508b650
--- /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", "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..c9eae527
--- /dev/null
+++ b/reservation/migrations/0001_initial.py
@@ -0,0 +1,144 @@
+# Generated by Django 5.2.1 on 2025-06-05 10:44
+
+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,
+                    ),
+                ),
+                (
+                    "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",
+                    ),
+                ),
+                (
+                    "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..be70f2e2
--- /dev/null
+++ b/reservation/models.py
@@ -0,0 +1,101 @@
+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, 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"),
+        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
+
+
+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 (
+            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 5a7e8e1b..df920a76 100644
--- a/sith/settings.py
+++ b/sith/settings.py
@@ -122,6 +122,7 @@ INSTALLED_APPS = (
     "trombi",
     "matmat",
     "pedagogy",
+    "reservation",
     "galaxy",
     "antispam",
 )