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",
|
||||
"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/"
|
||||
|
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