reservations: Add new app with Resource/Reservation/UserReservation models

This commit is contained in:
Adam Goldsmith 2024-08-05 22:04:15 -04:00
parent 508baf809c
commit 075812face
10 changed files with 402 additions and 0 deletions

View File

@ -57,6 +57,7 @@ class Base(Configuration):
"paperwork.apps.PaperworkConfig", "paperwork.apps.PaperworkConfig",
"doorcontrol.apps.DoorControlConfig", "doorcontrol.apps.DoorControlConfig",
"dashboard.apps.DashboardConfig", "dashboard.apps.DashboardConfig",
"reservations.apps.ReservationsConfig",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@ -101,6 +102,9 @@ class Base(Configuration):
WSGI_APPLICATION = "cmsmanage.wsgi.application" 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 # Default URL to redirect to after authentication
LOGIN_REDIRECT_URL = "/" LOGIN_REDIRECT_URL = "/"
LOGIN_URL = "/auth/login/" LOGIN_URL = "/auth/login/"

0
reservations/__init__.py Normal file
View File

28
reservations/admin.py Normal file
View File

@ -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"]

6
reservations/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ReservationsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "reservations"

View File

@ -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",),
),
]

View File

134
reservations/models.py Normal file
View File

@ -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),
}

View File

View File

@ -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),
)
)

1
reservations/views.py Normal file
View File

@ -0,0 +1 @@
# Create your views here.