reservations: Add new app with Resource/Reservation/UserReservation models
This commit is contained in:
parent
508baf809c
commit
075812face
@ -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
0
reservations/__init__.py
Normal file
28
reservations/admin.py
Normal file
28
reservations/admin.py
Normal 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
6
reservations/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ReservationsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "reservations"
|
113
reservations/migrations/0001_initial.py
Normal file
113
reservations/migrations/0001_initial.py
Normal 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",),
|
||||||
|
),
|
||||||
|
]
|
0
reservations/migrations/__init__.py
Normal file
0
reservations/migrations/__init__.py
Normal file
134
reservations/models.py
Normal file
134
reservations/models.py
Normal 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),
|
||||||
|
}
|
0
reservations/tests/__init__.py
Normal file
0
reservations/tests/__init__.py
Normal file
116
reservations/tests/test_Reservation.py
Normal file
116
reservations/tests/test_Reservation.py
Normal 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
1
reservations/views.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Create your views here.
|
Loading…
Reference in New Issue
Block a user