Skip to content

Commit 85c2236

Browse files
committed
feat: CalendarGroup models, querysets, and admin
1 parent 4cfe9e8 commit 85c2236

7 files changed

Lines changed: 850 additions & 2 deletions

File tree

calendar_integration/admin.py

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,14 @@
99
from django.utils.html import format_html
1010

1111
from calendar_integration.constants import IncomingWebhookProcessingStatus
12-
from calendar_integration.models import CalendarWebhookEvent, CalendarWebhookSubscription
12+
from calendar_integration.models import (
13+
CalendarEventGroupSelection,
14+
CalendarGroup,
15+
CalendarGroupSlot,
16+
CalendarGroupSlotMembership,
17+
CalendarWebhookEvent,
18+
CalendarWebhookSubscription,
19+
)
1320

1421

1522
class CalendarWebhookEventInline(admin.TabularInline):
@@ -298,6 +305,99 @@ def reprocess_events(self, request: HttpRequest, queryset):
298305
self.message_user(request, f"Reset {count} events for reprocessing.")
299306

300307

308+
class CalendarGroupSlotMembershipInline(admin.TabularInline):
309+
"""Inline admin for memberships within a CalendarGroupSlot."""
310+
311+
model = CalendarGroupSlotMembership
312+
fk_name = "slot_fk"
313+
fields = ("calendar_fk",)
314+
extra = 1
315+
316+
317+
class CalendarGroupSlotInline(admin.TabularInline):
318+
"""Inline admin for slots within a CalendarGroup."""
319+
320+
model = CalendarGroupSlot
321+
fk_name = "group_fk"
322+
fields = ("name", "order", "required_count", "description")
323+
extra = 1
324+
325+
326+
@admin.register(CalendarGroup)
327+
class CalendarGroupAdmin(admin.ModelAdmin):
328+
"""Admin interface for CalendarGroup."""
329+
330+
list_display = ("id", "name", "organization", "slot_count", "created")
331+
list_filter = ("organization", "created")
332+
search_fields = ("name", "description")
333+
readonly_fields = ("created", "modified")
334+
fields = ("organization", "name", "description", "created", "modified")
335+
inlines: ClassVar = [CalendarGroupSlotInline]
336+
337+
def get_queryset(self, request: HttpRequest):
338+
return (
339+
super()
340+
.get_queryset(request)
341+
.select_related("organization")
342+
.annotate(_slot_count=Count("slots"))
343+
)
344+
345+
@admin.display(description="Slots", ordering="_slot_count")
346+
def slot_count(self, obj: CalendarGroup) -> int:
347+
return getattr(obj, "_slot_count", obj.slots.count())
348+
349+
350+
@admin.register(CalendarGroupSlot)
351+
class CalendarGroupSlotAdmin(admin.ModelAdmin):
352+
"""Admin interface for CalendarGroupSlot."""
353+
354+
list_display = ("id", "name", "group", "order", "required_count", "calendar_count")
355+
list_filter = ("group_fk", "organization")
356+
search_fields = ("name", "group_fk__name")
357+
readonly_fields = ("created", "modified")
358+
fields = (
359+
"organization",
360+
"group_fk",
361+
"name",
362+
"description",
363+
"order",
364+
"required_count",
365+
"created",
366+
"modified",
367+
)
368+
inlines: ClassVar = [CalendarGroupSlotMembershipInline]
369+
370+
def get_queryset(self, request: HttpRequest):
371+
return (
372+
super()
373+
.get_queryset(request)
374+
.select_related("organization", "group_fk")
375+
.annotate(_calendar_count=Count("memberships"))
376+
)
377+
378+
@admin.display(description="Calendars", ordering="_calendar_count")
379+
def calendar_count(self, obj: CalendarGroupSlot) -> int:
380+
return getattr(obj, "_calendar_count", obj.memberships.count())
381+
382+
383+
@admin.register(CalendarEventGroupSelection)
384+
class CalendarEventGroupSelectionAdmin(admin.ModelAdmin):
385+
"""Admin interface for CalendarEventGroupSelection."""
386+
387+
list_display = ("id", "event", "slot", "calendar", "created")
388+
list_filter = ("organization", "created")
389+
search_fields = ("event_fk__title", "slot_fk__name", "calendar_fk__name")
390+
readonly_fields = ("created", "modified")
391+
fields = ("organization", "event_fk", "slot_fk", "calendar_fk", "created", "modified")
392+
393+
def get_queryset(self, request: HttpRequest):
394+
return (
395+
super()
396+
.get_queryset(request)
397+
.select_related("organization", "event_fk", "slot_fk", "calendar_fk")
398+
)
399+
400+
301401
class WebhookHealthDashboard:
302402
"""Dashboard widget for webhook health overview."""
303403

calendar_integration/managers.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
from collections.abc import Iterable
33

44
from calendar_integration.querysets import (
5+
CalendarEventGroupSelectionQuerySet,
56
CalendarEventQuerySet,
7+
CalendarGroupQuerySet,
8+
CalendarGroupSlotMembershipQuerySet,
9+
CalendarGroupSlotQuerySet,
610
CalendarQuerySet,
711
CalendarSyncQuerySet,
812
RecurringQuerySetMixin,
@@ -156,3 +160,40 @@ def get_queryset(self):
156160
from calendar_integration.querysets import AvailableTimeQuerySet
157161

158162
return AvailableTimeQuerySet(self.model, using=self._db)
163+
164+
165+
class CalendarGroupManager(BaseOrganizationModelManager):
166+
"""Custom manager for CalendarGroup model to handle specific queries."""
167+
168+
def get_queryset(self) -> CalendarGroupQuerySet:
169+
return CalendarGroupQuerySet(self.model, using=self._db)
170+
171+
def only_groups_bookable_in_ranges(
172+
self, ranges: Iterable[tuple[datetime.datetime, datetime.datetime]]
173+
):
174+
"""
175+
Returns groups where every slot has at least `required_count` calendars
176+
from its pool available in every requested range.
177+
"""
178+
return self.get_queryset().only_groups_bookable_in_ranges(ranges=ranges)
179+
180+
181+
class CalendarGroupSlotManager(BaseOrganizationModelManager):
182+
"""Custom manager for CalendarGroupSlot model to handle specific queries."""
183+
184+
def get_queryset(self) -> CalendarGroupSlotQuerySet:
185+
return CalendarGroupSlotQuerySet(self.model, using=self._db)
186+
187+
188+
class CalendarGroupSlotMembershipManager(BaseOrganizationModelManager):
189+
"""Custom manager for CalendarGroupSlotMembership model to handle specific queries."""
190+
191+
def get_queryset(self) -> CalendarGroupSlotMembershipQuerySet:
192+
return CalendarGroupSlotMembershipQuerySet(self.model, using=self._db)
193+
194+
195+
class CalendarEventGroupSelectionManager(BaseOrganizationModelManager):
196+
"""Custom manager for CalendarEventGroupSelection model to handle specific queries."""
197+
198+
def get_queryset(self) -> CalendarEventGroupSelectionQuerySet:
199+
return CalendarEventGroupSelectionQuerySet(self.model, using=self._db)
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# Generated by Django 5.2.2 on 2026-04-20 23:23
2+
3+
import django.db.models.deletion
4+
import django.utils.timezone
5+
import model_utils.fields
6+
from django.db import migrations, models
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
('calendar_integration', '0009_fix_webhook_unique_constraint'),
13+
('organizations', '0003_organizationinvitation'),
14+
]
15+
16+
operations = [
17+
migrations.CreateModel(
18+
name='CalendarGroup',
19+
fields=[
20+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
21+
('created', model_utils.fields.AutoCreatedField(db_index=True, default=django.utils.timezone.now, editable=False, verbose_name='created')),
22+
('modified', model_utils.fields.AutoLastModifiedField(db_index=True, default=django.utils.timezone.now, editable=False, verbose_name='modified')),
23+
('meta', models.JSONField(blank=True, default=dict, verbose_name='meta')),
24+
('name', models.CharField(max_length=255)),
25+
('description', models.TextField(blank=True)),
26+
('organization', models.ForeignKey(help_text='The organization this model is associated with. Queries should use the `organization` field.', on_delete=django.db.models.deletion.CASCADE, related_name='+', to='organizations.organization')),
27+
],
28+
),
29+
migrations.AddField(
30+
model_name='calendarevent',
31+
name='calendar_group',
32+
field=models.ForeignObject(editable=False, from_fields=['calendar_group_fk', 'organization_id'], null=True, on_delete=django.db.models.deletion.PROTECT, related_name='events', to='calendar_integration.calendargroup', to_fields=['id', 'organization_id']),
33+
),
34+
migrations.AddField(
35+
model_name='calendarevent',
36+
name='calendar_group_fk',
37+
field=models.ForeignKey(blank=True, help_text='If this event was booked through a CalendarGroup, references it', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='events_fk_rel', to='calendar_integration.calendargroup'),
38+
),
39+
migrations.CreateModel(
40+
name='CalendarGroupSlot',
41+
fields=[
42+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
43+
('created', model_utils.fields.AutoCreatedField(db_index=True, default=django.utils.timezone.now, editable=False, verbose_name='created')),
44+
('modified', model_utils.fields.AutoLastModifiedField(db_index=True, default=django.utils.timezone.now, editable=False, verbose_name='modified')),
45+
('meta', models.JSONField(blank=True, default=dict, verbose_name='meta')),
46+
('name', models.CharField(max_length=255)),
47+
('description', models.TextField(blank=True)),
48+
('order', models.PositiveSmallIntegerField(default=0)),
49+
('required_count', models.PositiveSmallIntegerField(default=1, help_text='How many calendars from the pool must be selected when booking. Default 1; use larger values when a slot needs multiple calendars (e.g. two nurses).')),
50+
('group', models.ForeignObject(editable=False, from_fields=['group_fk', 'organization_id'], on_delete=django.db.models.deletion.CASCADE, related_name='slots', to='calendar_integration.calendargroup', to_fields=['id', 'organization_id'])),
51+
('group_fk', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='slots_fk_rel', to='calendar_integration.calendargroup')),
52+
('organization', models.ForeignKey(help_text='The organization this model is associated with. Queries should use the `organization` field.', on_delete=django.db.models.deletion.CASCADE, related_name='+', to='organizations.organization')),
53+
],
54+
options={
55+
'ordering': ('order', 'id'),
56+
},
57+
),
58+
migrations.CreateModel(
59+
name='CalendarEventGroupSelection',
60+
fields=[
61+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
62+
('created', model_utils.fields.AutoCreatedField(db_index=True, default=django.utils.timezone.now, editable=False, verbose_name='created')),
63+
('modified', model_utils.fields.AutoLastModifiedField(db_index=True, default=django.utils.timezone.now, editable=False, verbose_name='modified')),
64+
('meta', models.JSONField(blank=True, default=dict, verbose_name='meta')),
65+
('calendar', models.ForeignObject(editable=False, from_fields=['calendar_fk', 'organization_id'], on_delete=django.db.models.deletion.PROTECT, related_name='group_selections', to='calendar_integration.calendar', to_fields=['id', 'organization_id'])),
66+
('calendar_fk', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='group_selections_fk_rel', to='calendar_integration.calendar')),
67+
('event', models.ForeignObject(editable=False, from_fields=['event_fk', 'organization_id'], on_delete=django.db.models.deletion.CASCADE, related_name='group_selections', to='calendar_integration.calendarevent', to_fields=['id', 'organization_id'])),
68+
('event_fk', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_selections_fk_rel', to='calendar_integration.calendarevent')),
69+
('organization', models.ForeignKey(help_text='The organization this model is associated with. Queries should use the `organization` field.', on_delete=django.db.models.deletion.CASCADE, related_name='+', to='organizations.organization')),
70+
('slot', models.ForeignObject(editable=False, from_fields=['slot_fk', 'organization_id'], on_delete=django.db.models.deletion.PROTECT, related_name='selections', to='calendar_integration.calendargroupslot', to_fields=['id', 'organization_id'])),
71+
('slot_fk', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='selections_fk_rel', to='calendar_integration.calendargroupslot')),
72+
],
73+
),
74+
migrations.CreateModel(
75+
name='CalendarGroupSlotMembership',
76+
fields=[
77+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
78+
('created', model_utils.fields.AutoCreatedField(db_index=True, default=django.utils.timezone.now, editable=False, verbose_name='created')),
79+
('modified', model_utils.fields.AutoLastModifiedField(db_index=True, default=django.utils.timezone.now, editable=False, verbose_name='modified')),
80+
('meta', models.JSONField(blank=True, default=dict, verbose_name='meta')),
81+
('calendar', models.ForeignObject(editable=False, from_fields=['calendar_fk', 'organization_id'], on_delete=django.db.models.deletion.CASCADE, related_name='group_slot_memberships', to='calendar_integration.calendar', to_fields=['id', 'organization_id'])),
82+
('calendar_fk', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_slot_memberships_fk_rel', to='calendar_integration.calendar')),
83+
('organization', models.ForeignKey(help_text='The organization this model is associated with. Queries should use the `organization` field.', on_delete=django.db.models.deletion.CASCADE, related_name='+', to='organizations.organization')),
84+
('slot', models.ForeignObject(editable=False, from_fields=['slot_fk', 'organization_id'], on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='calendar_integration.calendargroupslot', to_fields=['id', 'organization_id'])),
85+
('slot_fk', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships_fk_rel', to='calendar_integration.calendargroupslot')),
86+
],
87+
),
88+
migrations.AddField(
89+
model_name='calendargroupslot',
90+
name='calendars',
91+
field=models.ManyToManyField(related_name='group_slots', through='calendar_integration.CalendarGroupSlotMembership', through_fields=('slot', 'calendar'), to='calendar_integration.calendar'),
92+
),
93+
migrations.AddConstraint(
94+
model_name='calendargroup',
95+
constraint=models.UniqueConstraint(fields=('organization', 'name'), name='calendargroup_unique_name_per_org'),
96+
),
97+
migrations.AddConstraint(
98+
model_name='calendareventgroupselection',
99+
constraint=models.UniqueConstraint(fields=('event_fk', 'slot_fk', 'calendar_fk'), name='calendareventgroupselection_unique'),
100+
),
101+
migrations.AddConstraint(
102+
model_name='calendargroupslotmembership',
103+
constraint=models.UniqueConstraint(fields=('slot_fk', 'calendar_fk'), name='calendargroupslotmembership_unique_slot_calendar'),
104+
),
105+
migrations.AddConstraint(
106+
model_name='calendargroupslot',
107+
constraint=models.UniqueConstraint(fields=('group_fk', 'name'), name='calendargroupslot_unique_name_per_group'),
108+
),
109+
]

0 commit comments

Comments
 (0)