From 075812face4d4bc30d7bfa48f46c693a58de3e3a Mon Sep 17 00:00:00 2001 From: Adam Goldsmith Date: Mon, 5 Aug 2024 22:04:15 -0400 Subject: [PATCH] reservations: Add new app with Resource/Reservation/UserReservation models --- cmsmanage/settings.py | 4 + reservations/__init__.py | 0 reservations/admin.py | 28 +++++ reservations/apps.py | 6 ++ reservations/migrations/0001_initial.py | 113 ++++++++++++++++++++ reservations/migrations/__init__.py | 0 reservations/models.py | 134 ++++++++++++++++++++++++ reservations/tests/__init__.py | 0 reservations/tests/test_Reservation.py | 116 ++++++++++++++++++++ reservations/views.py | 1 + 10 files changed, 402 insertions(+) create mode 100644 reservations/__init__.py create mode 100644 reservations/admin.py create mode 100644 reservations/apps.py create mode 100644 reservations/migrations/0001_initial.py create mode 100644 reservations/migrations/__init__.py create mode 100644 reservations/models.py create mode 100644 reservations/tests/__init__.py create mode 100644 reservations/tests/test_Reservation.py create mode 100644 reservations/views.py diff --git a/cmsmanage/settings.py b/cmsmanage/settings.py index aafd9cd..75f97d1 100644 --- a/cmsmanage/settings.py +++ b/cmsmanage/settings.py @@ -57,6 +57,7 @@ class Base(Configuration): "paperwork.apps.PaperworkConfig", "doorcontrol.apps.DoorControlConfig", "dashboard.apps.DashboardConfig", + "reservations.apps.ReservationsConfig", ] MIDDLEWARE = [ @@ -101,6 +102,9 @@ class Base(Configuration): WSGI_APPLICATION = "cmsmanage.wsgi.application" + # mysql.W003 (unique CharField length) is irrelevant on MariaDB >= 10.4.3 + SILENCED_SYSTEM_CHECKS = ["mysql.W003"] + # Default URL to redirect to after authentication LOGIN_REDIRECT_URL = "/" LOGIN_URL = "/auth/login/" diff --git a/reservations/__init__.py b/reservations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/reservations/admin.py b/reservations/admin.py new file mode 100644 index 0000000..3640aab --- /dev/null +++ b/reservations/admin.py @@ -0,0 +1,28 @@ +from django.contrib import admin + +from .models import Reservation, Resource, UserReservation + + +@admin.register(Resource) +class ResourceAdmin(admin.ModelAdmin): + list_display = ["name", "parent", "min_length", "max_length"] + list_filter = ["parent"] + search_fields = ["name"] + show_facets = admin.ShowFacets.ALWAYS + + +@admin.register(Reservation) +class ReservationAdmin(admin.ModelAdmin): + list_display = ["_resources", "start", "end"] + readonly_fields = ["google_calendar_event_id"] + list_filter = ["resources"] + show_facets = admin.ShowFacets.ALWAYS + + @admin.display() + def _resources(self, obj: Reservation): + return list(obj.resources.all()) or None + + +@admin.register(UserReservation) +class UserReservationAdmin(ReservationAdmin): + list_display = ["_resources", "user", "start", "end"] diff --git a/reservations/apps.py b/reservations/apps.py new file mode 100644 index 0000000..2dbb2ca --- /dev/null +++ b/reservations/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ReservationsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "reservations" diff --git a/reservations/migrations/0001_initial.py b/reservations/migrations/0001_initial.py new file mode 100644 index 0000000..df3d347 --- /dev/null +++ b/reservations/migrations/0001_initial.py @@ -0,0 +1,113 @@ +# Generated by Django 5.0.7 on 2024-08-05 20:43 + +import django.db.models.deletion +import django.db.models.expressions +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Resource", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=256)), + ("min_length", models.DurationField()), + ("max_length", models.DurationField()), + ("google_calendar", models.CharField(max_length=1024, unique=True)), + ( + "parent", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="children", + to="reservations.resource", + ), + ), + ], + ), + migrations.CreateModel( + name="Reservation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("start", models.DateTimeField()), + ("end", models.DateTimeField()), + ( + "google_calendar_event_id", + models.CharField( + blank=True, max_length=1024, null=True, unique=True + ), + ), + ( + "duration", + models.GeneratedField( + db_persist=False, + expression=django.db.models.expressions.CombinedExpression( + models.F("end"), "-", models.F("start") + ), + output_field=models.DurationField(), + ), + ), + ( + "resources", + models.ManyToManyField(blank=True, to="reservations.resource"), + ), + ], + ), + migrations.AddConstraint( + model_name="reservation", + constraint=models.CheckConstraint( + check=models.Q(("end__gt", models.F("start"))), name="end_after_start" + ), + ), + migrations.CreateModel( + name="UserReservation", + fields=[ + ( + "reservation_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="reservations.reservation", + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + bases=("reservations.reservation",), + ), + ] diff --git a/reservations/migrations/__init__.py b/reservations/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/reservations/models.py b/reservations/models.py new file mode 100644 index 0000000..06cb9a2 --- /dev/null +++ b/reservations/models.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +from collections.abc import Iterable +from datetime import datetime + +from django.contrib.auth import get_user_model +from django.db import models +from django.db.models import F, Q, QuerySet +from django.utils import timezone + +from model_utils.managers import InheritanceQuerySetMixin + + +class Resource(models.Model): + name = models.CharField(max_length=256) + parent = models.ForeignKey( + "Resource", + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="children", + ) + min_length = models.DurationField() + max_length = models.DurationField() + google_calendar = models.CharField(max_length=1024, unique=True) + + def __str__(self) -> str: + if self.parent: + return f"{self.parent} / {self.name}" + else: + return self.name + + def get_recursive_parents(self) -> Iterable[Resource]: + if self.parent: + yield self.parent + yield from self.parent.get_recursive_parents() + + def get_recursive_children(self) -> Iterable[Resource]: + for child in self.children.all(): + yield child + yield from child.get_recursive_children() + + def get_related(self) -> Iterable[Resource]: + yield self + yield from self.get_recursive_parents() + yield from self.get_recursive_children() + + +class ReservationQuerySet(InheritanceQuerySetMixin, QuerySet): + def filter_after(self, start: datetime) -> ReservationQuerySet: + """ + Selects events that are after the specified datetime, including + overlaps but excluding exactly matching endpoints. + """ + return self.filter(Q(start__gt=start) | Q(end__gt=start)) + + def filter_before(self, end: datetime) -> ReservationQuerySet: + """ + Selects events that are before the specified datetime, including + overlaps but excluding exactly matching endpoints. + """ + return self.filter(Q(start__lt=end) | Q(end__lt=end)) + + def filter_between(self, start: datetime, end: datetime) -> ReservationQuerySet: + """ + Selects events that are between the specified datetime, including + overlaps but excluding exactly matching endpoints. + """ + return self.filter( + Q(start__gt=start, start__lt=end) + | Q(end__gt=start, end__lt=end) + | (Q(start__lt=start) & Q(end__gt=end)) + ) + + +class Reservation(models.Model): + resources = models.ManyToManyField(Resource, blank=True) + start = models.DateTimeField() + end = models.DateTimeField() + google_calendar_event_id = models.CharField( + max_length=1024, null=True, blank=True, unique=True + ) + + duration = models.GeneratedField( + expression=F("end") - F("start"), + output_field=models.DurationField(), + db_persist=False, + ) + + objects = ReservationQuerySet.as_manager() + + class Meta: + constraints = [ + models.CheckConstraint(check=Q(end__gt=F("start")), name="end_after_start"), + ] + + def __str__(self) -> str: + resources = ", ".join(str(resource) for resource in self.resources.all()) + return f"{resources}: {self.start} - {self.end}" + + def make_google_calendar_event(self): + event = { + "summary": "CMSManage Reservation", + "start": { + "dateTime": self.start.isoformat(), + "timeZone": timezone.get_default_timezone_name(), + }, + "end": { + "dateTime": self.end.isoformat(), + "timeZone": timezone.get_default_timezone_name(), + }, + "extendedProperties": {"private": {"cmsmanage": "1"}}, + "status": "confirmed", + } + + # use existing id if it exists, otherwise let Google generate it + if self.google_calendar_event_id: + event["id"] = self.google_calendar_event_id + + return event + + +class UserReservation(Reservation): + user = models.ForeignKey( + get_user_model(), on_delete=models.CASCADE, null=True, blank=True + ) + + def __str__(self) -> str: + return f"{self.user} | {super().__str__()}" + + def make_google_calendar_event(self): + return super().make_google_calendar_event() | { + "summary": str(self.user), + } diff --git a/reservations/tests/__init__.py b/reservations/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/reservations/tests/test_Reservation.py b/reservations/tests/test_Reservation.py new file mode 100644 index 0000000..8bddea9 --- /dev/null +++ b/reservations/tests/test_Reservation.py @@ -0,0 +1,116 @@ +from datetime import datetime, timedelta + +from django.test import TestCase +from django.utils import timezone + +from reservations.models import Reservation, Resource + + +class ReservationRangesTestCase(TestCase): + def setUp(self): + tz = timezone.get_current_timezone() + test_resource = Resource.objects.create( + name="test resource", + min_length=timedelta(minutes=15), + max_length=timedelta(hours=4), + ) + test_reservation = Reservation.objects.create( + start=datetime(2021, 12, 8, 12, 00, tzinfo=tz), + end=datetime(2021, 12, 8, 14, 0, tzinfo=tz), + ) + test_reservation.resources.add(test_resource) + + def test_after(self): + tz = timezone.get_current_timezone() + + self.assertTrue( + Reservation.objects.filter_after(datetime(2021, 12, 8, 11, 0, tzinfo=tz)) + ) + self.assertTrue( + Reservation.objects.filter_after(datetime(2021, 12, 8, 12, 0, tzinfo=tz)) + ) + self.assertTrue( + Reservation.objects.filter_after(datetime(2021, 12, 8, 13, 0, tzinfo=tz)) + ) + + self.assertFalse( + Reservation.objects.filter_after(datetime(2021, 12, 8, 14, 00, tzinfo=tz)) + ) + self.assertFalse( + Reservation.objects.filter_after(datetime(2021, 12, 8, 15, 00, tzinfo=tz)) + ) + + def test_before(self): + tz = timezone.get_current_timezone() + + self.assertTrue( + Reservation.objects.filter_before(datetime(2021, 12, 8, 13, 0, tzinfo=tz)) + ) + self.assertTrue( + Reservation.objects.filter_before(datetime(2021, 12, 8, 14, 0, tzinfo=tz)) + ) + self.assertTrue( + Reservation.objects.filter_before(datetime(2021, 12, 8, 15, 0, tzinfo=tz)) + ) + + self.assertFalse( + Reservation.objects.filter_before(datetime(2021, 12, 8, 11, 00, tzinfo=tz)) + ) + self.assertFalse( + Reservation.objects.filter_before(datetime(2021, 12, 8, 12, 00, tzinfo=tz)) + ) + + def test_between(self): + tz = timezone.get_current_timezone() + + # contained (reservation entirely inside both start and end) + self.assertTrue( + Reservation.objects.filter_between( + datetime(2021, 12, 8, 11, 0, tzinfo=tz), + datetime(2021, 12, 8, 15, 0, tzinfo=tz), + ) + ) + + # overlapping edges + self.assertTrue( + Reservation.objects.filter_between( + datetime(2021, 12, 8, 11, 0, tzinfo=tz), + datetime(2021, 12, 8, 13, 0, tzinfo=tz), + ) + ) + self.assertTrue( + Reservation.objects.filter_between( + datetime(2021, 12, 8, 13, 0, tzinfo=tz), + datetime(2021, 12, 8, 15, 0, tzinfo=tz), + ) + ) + + # containing (reservation contains both start and end) + self.assertTrue( + Reservation.objects.filter_between( + datetime(2021, 12, 8, 12, 30, tzinfo=tz), + datetime(2021, 12, 8, 13, 30, tzinfo=tz), + ) + ) + + # on boundries + self.assertFalse( + Reservation.objects.filter_between( + datetime(2021, 12, 8, 11, 0, tzinfo=tz), + datetime(2021, 12, 8, 12, 0, tzinfo=tz), + ) + ) + self.assertFalse( + Reservation.objects.filter_between( + datetime(2021, 12, 8, 14, 0, tzinfo=tz), + datetime(2021, 12, 8, 15, 0, tzinfo=tz), + ) + ) + + # outside + self.assertFalse( + Reservation.objects.filter_between( + datetime(2021, 12, 8, 15, 0, tzinfo=tz), + datetime(2021, 12, 8, 17, 0, tzinfo=tz), + ) + ) diff --git a/reservations/views.py b/reservations/views.py new file mode 100644 index 0000000..60f00ef --- /dev/null +++ b/reservations/views.py @@ -0,0 +1 @@ +# Create your views here.