Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions calendar_integration/managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,17 @@ def only_calendars_available_in_ranges(
"""
return self.get_queryset().only_calendars_available_in_ranges(ranges=ranges)

def only_calendars_available_in_ranges_with_bulk_modifications(
self, ranges: Iterable[tuple[datetime.datetime, datetime.datetime]]
):
"""
Same as `only_calendars_available_in_ranges` but expands recurring events
through their bulk-modification continuations.
"""
return self.get_queryset().only_calendars_available_in_ranges_with_bulk_modifications(
ranges=ranges
)


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

def only_groups_bookable_in_ranges_with_bulk_modifications(
self, ranges: Iterable[tuple[datetime.datetime, datetime.datetime]]
):
"""
Same as `only_groups_bookable_in_ranges` but expands recurring events
through their bulk-modification continuations when computing calendar
availability per slot.
"""
return self.get_queryset().only_groups_bookable_in_ranges_with_bulk_modifications(
ranges=ranges
)


class CalendarGroupSlotManager(BaseOrganizationModelManager):
"""Custom manager for CalendarGroupSlot model to handle specific queries."""
Expand Down
32 changes: 22 additions & 10 deletions calendar_integration/permissions.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from dependency_injector.wiring import Provide, inject
from rest_framework.permissions import BasePermission

from calendar_integration.models import CalendarOwnership
from calendar_integration.services.calendar_permission_service import CalendarPermissionService


class CalendarEventPermission(BasePermission):
Expand Down Expand Up @@ -44,12 +46,20 @@ class CalendarGroupPermission(BasePermission):

- `has_permission` requires an authenticated user with an active
organization membership; list/create are org-scoped by the viewset.
- `has_object_permission` additionally requires the user to own at least
one calendar inside the group's slots — matching the plan's "owns at
least one calendar in the group" heuristic. An org-admin override is a
follow-up.
- `has_object_permission` delegates the "can this user manage this group"
decision to `CalendarPermissionService.can_manage_calendar_group` so the
rule (and future org-admin override) has a single implementation.
"""

@inject
def __init__(
self,
calendar_permission_service: "CalendarPermissionService | None" = Provide[
"calendar_permission_service"
],
):
self.calendar_permission_service = calendar_permission_service

def has_permission(self, request, view):
user = request.user
if not user.is_authenticated:
Expand All @@ -59,11 +69,13 @@ def has_permission(self, request, view):
def has_object_permission(self, request, view, obj):
if obj.organization_id != request.user.organization_membership.organization_id:
return False
return (
CalendarOwnership.objects.filter_by_organization(obj.organization_id)
.filter(
user=request.user,
calendar__group_slots__group_fk=obj,
if self.calendar_permission_service is None:
# Fallback if DI isn't wired (should not happen in normal flows).
return (
CalendarOwnership.objects.filter_by_organization(obj.organization_id)
.filter(user=request.user, calendar_fk__group_slots__group_fk=obj)
.exists()
)
.exists()
return self.calendar_permission_service.can_manage_calendar_group(
user=request.user, group=obj
)
73 changes: 62 additions & 11 deletions calendar_integration/querysets.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,27 @@ def only_calendars_available_in_ranges(
"""
Returns calendars that have available time windows in all specified ranges.
"""
return self._only_calendars_available_in_ranges(ranges, with_bulk_modifications=False)

def only_calendars_available_in_ranges_with_bulk_modifications(
self, ranges: Iterable[tuple[datetime.datetime, datetime.datetime]]
):
"""
Same as `only_calendars_available_in_ranges`, but recurring events and
blocked times are expanded via their bulk-modification continuation
series (`annotate_recurring_occurrences_with_bulk_modifications_on_date_range`).
Use this when the caller has split a recurring series with a bulk
modification and needs the continuation occurrences to count against
availability.
"""
return self._only_calendars_available_in_ranges(ranges, with_bulk_modifications=True)

def _only_calendars_available_in_ranges(
self,
ranges: Iterable[tuple[datetime.datetime, datetime.datetime]],
*,
with_bulk_modifications: bool,
):
from calendar_integration.models import AvailableTime, BlockedTime, CalendarEvent

if not ranges:
Expand All @@ -236,20 +257,28 @@ def only_calendars_available_in_ranges(
),
)

if with_bulk_modifications:
events_qs = CalendarEvent.objects.annotate_recurring_occurrences_with_bulk_modifications_on_date_range(
start_datetime, end_datetime
)
recurring_occurrences_field = "recurring_occurrences"
else:
events_qs = CalendarEvent.objects.annotate_recurring_occurrences_on_date_range(
start_datetime, end_datetime
)
recurring_occurrences_field = "recurring_occurrences"

# For unmanaged calendars: must NOT have conflicting events or blocked times
unmanaged_query = Q(
manage_available_windows=False,
) & ~Q(
Q(
id__in=Subquery(
CalendarEvent.objects.annotate_recurring_occurrences_on_date_range(
start_datetime, end_datetime
)
.filter(
events_qs.filter(
Q(start_time__range=(start_datetime, end_datetime))
| Q(end_time__range=(start_datetime, end_datetime))
| Q(start_time__lte=start_datetime, end_time__gte=end_datetime)
| Q(recurring_occurrences__len__gt=0),
| Q(**{f"{recurring_occurrences_field}__len__gt": 0}),
calendar_fk_id=OuterRef("id"),
)
.values("calendar_fk_id")
Expand Down Expand Up @@ -381,20 +410,42 @@ def only_groups_bookable_in_ranges(
`required_count` calendars from its pool available
(per CalendarQuerySet.only_calendars_available_in_ranges).
"""
return self._only_groups_bookable_in_ranges(ranges, with_bulk_modifications=False)

def only_groups_bookable_in_ranges_with_bulk_modifications(
self, ranges: Iterable[tuple[datetime.datetime, datetime.datetime]]
):
"""
Same as `only_groups_bookable_in_ranges` but expands recurring events
through their bulk-modification continuation series so split-off
occurrences count against availability.
"""
return self._only_groups_bookable_in_ranges(ranges, with_bulk_modifications=True)

def _only_groups_bookable_in_ranges(
self,
ranges: Iterable[tuple[datetime.datetime, datetime.datetime]],
*,
with_bulk_modifications: bool,
):
from calendar_integration.models import Calendar, CalendarGroupSlot

ranges = list(ranges)
if not ranges:
return self.none()

calendar_method = (
"only_calendars_available_in_ranges_with_bulk_modifications"
if with_bulk_modifications
else "only_calendars_available_in_ranges"
)

qs = self
for start_datetime, end_datetime in ranges:
available_calendar_ids = (
Calendar.objects.get_queryset()
.filter(organization_id=OuterRef("organization_id"))
.only_calendars_available_in_ranges([(start_datetime, end_datetime)])
.values("id")
)
available_calendar_ids = getattr(
Calendar.objects.get_queryset().filter(organization_id=OuterRef("organization_id")),
calendar_method,
)([(start_datetime, end_datetime)]).values("id")
unsatisfied_slot = (
CalendarGroupSlot.objects.get_queryset()
.filter(group_fk_id=OuterRef("id"))
Expand Down
Loading
Loading