Skip to content

Commit ad1d220

Browse files
committed
feat: add bulk modification support for calendar and group availability queries
1 parent 5b80386 commit ad1d220

6 files changed

Lines changed: 633 additions & 36 deletions

File tree

calendar_integration/managers.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,17 @@ def only_calendars_available_in_ranges(
121121
"""
122122
return self.get_queryset().only_calendars_available_in_ranges(ranges=ranges)
123123

124+
def only_calendars_available_in_ranges_with_bulk_modifications(
125+
self, ranges: Iterable[tuple[datetime.datetime, datetime.datetime]]
126+
):
127+
"""
128+
Same as `only_calendars_available_in_ranges` but expands recurring events
129+
through their bulk-modification continuations.
130+
"""
131+
return self.get_queryset().only_calendars_available_in_ranges_with_bulk_modifications(
132+
ranges=ranges
133+
)
134+
124135

125136
class CalendarEventManager(BaseOrganizationModelManager, RecurringManagerMixin):
126137
"""Custom manager for CalendarEvent model to handle specific queries."""
@@ -177,6 +188,18 @@ def only_groups_bookable_in_ranges(
177188
"""
178189
return self.get_queryset().only_groups_bookable_in_ranges(ranges=ranges)
179190

191+
def only_groups_bookable_in_ranges_with_bulk_modifications(
192+
self, ranges: Iterable[tuple[datetime.datetime, datetime.datetime]]
193+
):
194+
"""
195+
Same as `only_groups_bookable_in_ranges` but expands recurring events
196+
through their bulk-modification continuations when computing calendar
197+
availability per slot.
198+
"""
199+
return self.get_queryset().only_groups_bookable_in_ranges_with_bulk_modifications(
200+
ranges=ranges
201+
)
202+
180203

181204
class CalendarGroupSlotManager(BaseOrganizationModelManager):
182205
"""Custom manager for CalendarGroupSlot model to handle specific queries."""

calendar_integration/permissions.py

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
from dependency_injector.wiring import Provide, inject
12
from rest_framework.permissions import BasePermission
23

34
from calendar_integration.models import CalendarOwnership
5+
from calendar_integration.services.calendar_permission_service import CalendarPermissionService
46

57

68
class CalendarEventPermission(BasePermission):
@@ -44,12 +46,20 @@ class CalendarGroupPermission(BasePermission):
4446
4547
- `has_permission` requires an authenticated user with an active
4648
organization membership; list/create are org-scoped by the viewset.
47-
- `has_object_permission` additionally requires the user to own at least
48-
one calendar inside the group's slots — matching the plan's "owns at
49-
least one calendar in the group" heuristic. An org-admin override is a
50-
follow-up.
49+
- `has_object_permission` delegates the "can this user manage this group"
50+
decision to `CalendarPermissionService.can_manage_calendar_group` so the
51+
rule (and future org-admin override) has a single implementation.
5152
"""
5253

54+
@inject
55+
def __init__(
56+
self,
57+
calendar_permission_service: "CalendarPermissionService | None" = Provide[
58+
"calendar_permission_service"
59+
],
60+
):
61+
self.calendar_permission_service = calendar_permission_service
62+
5363
def has_permission(self, request, view):
5464
user = request.user
5565
if not user.is_authenticated:
@@ -59,11 +69,13 @@ def has_permission(self, request, view):
5969
def has_object_permission(self, request, view, obj):
6070
if obj.organization_id != request.user.organization_membership.organization_id:
6171
return False
62-
return (
63-
CalendarOwnership.objects.filter_by_organization(obj.organization_id)
64-
.filter(
65-
user=request.user,
66-
calendar__group_slots__group_fk=obj,
72+
if self.calendar_permission_service is None:
73+
# Fallback if DI isn't wired (should not happen in normal flows).
74+
return (
75+
CalendarOwnership.objects.filter_by_organization(obj.organization_id)
76+
.filter(user=request.user, calendar_fk__group_slots__group_fk=obj)
77+
.exists()
6778
)
68-
.exists()
79+
return self.calendar_permission_service.can_manage_calendar_group(
80+
user=request.user, group=obj
6981
)

calendar_integration/querysets.py

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,27 @@ def only_calendars_available_in_ranges(
215215
"""
216216
Returns calendars that have available time windows in all specified ranges.
217217
"""
218+
return self._only_calendars_available_in_ranges(ranges, with_bulk_modifications=False)
219+
220+
def only_calendars_available_in_ranges_with_bulk_modifications(
221+
self, ranges: Iterable[tuple[datetime.datetime, datetime.datetime]]
222+
):
223+
"""
224+
Same as `only_calendars_available_in_ranges`, but recurring events and
225+
blocked times are expanded via their bulk-modification continuation
226+
series (`annotate_recurring_occurrences_with_bulk_modifications_on_date_range`).
227+
Use this when the caller has split a recurring series with a bulk
228+
modification and needs the continuation occurrences to count against
229+
availability.
230+
"""
231+
return self._only_calendars_available_in_ranges(ranges, with_bulk_modifications=True)
232+
233+
def _only_calendars_available_in_ranges(
234+
self,
235+
ranges: Iterable[tuple[datetime.datetime, datetime.datetime]],
236+
*,
237+
with_bulk_modifications: bool,
238+
):
218239
from calendar_integration.models import AvailableTime, BlockedTime, CalendarEvent
219240

220241
if not ranges:
@@ -236,20 +257,28 @@ def only_calendars_available_in_ranges(
236257
),
237258
)
238259

260+
if with_bulk_modifications:
261+
events_qs = CalendarEvent.objects.annotate_recurring_occurrences_with_bulk_modifications_on_date_range(
262+
start_datetime, end_datetime
263+
)
264+
recurring_occurrences_field = "recurring_occurrences"
265+
else:
266+
events_qs = CalendarEvent.objects.annotate_recurring_occurrences_on_date_range(
267+
start_datetime, end_datetime
268+
)
269+
recurring_occurrences_field = "recurring_occurrences"
270+
239271
# For unmanaged calendars: must NOT have conflicting events or blocked times
240272
unmanaged_query = Q(
241273
manage_available_windows=False,
242274
) & ~Q(
243275
Q(
244276
id__in=Subquery(
245-
CalendarEvent.objects.annotate_recurring_occurrences_on_date_range(
246-
start_datetime, end_datetime
247-
)
248-
.filter(
277+
events_qs.filter(
249278
Q(start_time__range=(start_datetime, end_datetime))
250279
| Q(end_time__range=(start_datetime, end_datetime))
251280
| Q(start_time__lte=start_datetime, end_time__gte=end_datetime)
252-
| Q(recurring_occurrences__len__gt=0),
281+
| Q(**{f"{recurring_occurrences_field}__len__gt": 0}),
253282
calendar_fk_id=OuterRef("id"),
254283
)
255284
.values("calendar_fk_id")
@@ -381,20 +410,42 @@ def only_groups_bookable_in_ranges(
381410
`required_count` calendars from its pool available
382411
(per CalendarQuerySet.only_calendars_available_in_ranges).
383412
"""
413+
return self._only_groups_bookable_in_ranges(ranges, with_bulk_modifications=False)
414+
415+
def only_groups_bookable_in_ranges_with_bulk_modifications(
416+
self, ranges: Iterable[tuple[datetime.datetime, datetime.datetime]]
417+
):
418+
"""
419+
Same as `only_groups_bookable_in_ranges` but expands recurring events
420+
through their bulk-modification continuation series so split-off
421+
occurrences count against availability.
422+
"""
423+
return self._only_groups_bookable_in_ranges(ranges, with_bulk_modifications=True)
424+
425+
def _only_groups_bookable_in_ranges(
426+
self,
427+
ranges: Iterable[tuple[datetime.datetime, datetime.datetime]],
428+
*,
429+
with_bulk_modifications: bool,
430+
):
384431
from calendar_integration.models import Calendar, CalendarGroupSlot
385432

386433
ranges = list(ranges)
387434
if not ranges:
388435
return self.none()
389436

437+
calendar_method = (
438+
"only_calendars_available_in_ranges_with_bulk_modifications"
439+
if with_bulk_modifications
440+
else "only_calendars_available_in_ranges"
441+
)
442+
390443
qs = self
391444
for start_datetime, end_datetime in ranges:
392-
available_calendar_ids = (
393-
Calendar.objects.get_queryset()
394-
.filter(organization_id=OuterRef("organization_id"))
395-
.only_calendars_available_in_ranges([(start_datetime, end_datetime)])
396-
.values("id")
397-
)
445+
available_calendar_ids = getattr(
446+
Calendar.objects.get_queryset().filter(organization_id=OuterRef("organization_id")),
447+
calendar_method,
448+
)([(start_datetime, end_datetime)]).values("id")
398449
unsatisfied_slot = (
399450
CalendarGroupSlot.objects.get_queryset()
400451
.filter(group_fk_id=OuterRef("id"))

0 commit comments

Comments
 (0)