diff --git a/calendar_integration/managers.py b/calendar_integration/managers.py index ff96629..f2c0a10 100644 --- a/calendar_integration/managers.py +++ b/calendar_integration/managers.py @@ -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.""" @@ -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.""" diff --git a/calendar_integration/permissions.py b/calendar_integration/permissions.py index 324b5d2..033bddf 100644 --- a/calendar_integration/permissions.py +++ b/calendar_integration/permissions.py @@ -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): @@ -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: @@ -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 ) diff --git a/calendar_integration/querysets.py b/calendar_integration/querysets.py index 08236ec..d217c64 100644 --- a/calendar_integration/querysets.py +++ b/calendar_integration/querysets.py @@ -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: @@ -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") @@ -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")) diff --git a/calendar_integration/services/calendar_group_service.py b/calendar_integration/services/calendar_group_service.py index 0a56b59..662feca 100644 --- a/calendar_integration/services/calendar_group_service.py +++ b/calendar_integration/services/calendar_group_service.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Annotated from django.db import transaction +from django.db.models import Q from django.utils import timezone from dependency_injector.wiring import Provide, inject @@ -15,6 +16,7 @@ CalendarServiceOrganizationNotSetError, ) from calendar_integration.models import ( + AvailableTime, BlockedTime, Calendar, CalendarEvent, @@ -43,6 +45,15 @@ from calendar_integration.services.calendar_service import CalendarService +def _intervals_overlap( + a: tuple[datetime.datetime, datetime.datetime], + b: tuple[datetime.datetime, datetime.datetime], +) -> bool: + a_start, a_end = a + b_start, b_end = b + return a_start < b_end and b_start < a_end + + class CalendarGroupService: organization: Organization | None @@ -298,10 +309,13 @@ def check_group_availability( self, group_id: int, ranges: Iterable[tuple[datetime.datetime, datetime.datetime]], + with_bulk_modifications: bool = False, ) -> list[CalendarGroupRangeAvailability]: """For every range, list which calendars in each slot's pool are available. A slot with an empty `available_calendar_ids` is unbookable for that range. + Set `with_bulk_modifications=True` to expand recurring events through + their bulk-modification continuation series. """ self._assert_initialized() group = self._get_group_by_id(group_id) @@ -317,12 +331,19 @@ def check_group_availability( for s in slots } + calendar_qs_method = ( + "only_calendars_available_in_ranges_with_bulk_modifications" + if with_bulk_modifications + else "only_calendars_available_in_ranges" + ) + results: list[CalendarGroupRangeAvailability] = [] for start, end in ranges: available_ids = set( - Calendar.objects.filter_by_organization(self.organization.id) - .only_calendars_available_in_ranges([(start, end)]) - .values_list("id", flat=True) + getattr( + Calendar.objects.filter_by_organization(self.organization.id), + calendar_qs_method, + )([(start, end)]).values_list("id", flat=True) ) slot_results = [ CalendarGroupSlotAvailability( @@ -623,12 +644,22 @@ def find_bookable_slots( search_window_end: datetime.datetime, duration: datetime.timedelta, slot_step: datetime.timedelta = datetime.timedelta(minutes=15), + with_bulk_modifications: bool = False, ) -> list[BookableSlotProposal]: - """Walk `[search_window_start, search_window_end]` in `slot_step` increments - and return the start/end pairs where every slot of the group is satisfied. - - v1 scans in Python, firing one availability query per step; we leave SQL-side - window generation for a follow-up (see plan PR5). + """Return every `(candidate_start, candidate_start + duration)` within + `[search_window_start, search_window_end]`, stepping by `slot_step`, + where every slot in the group has at least `required_count` calendars + available. + + The implementation fetches blocking data (AvailableTime for managed + calendars, CalendarEvent + BlockedTime for unmanaged calendars) once + for the whole search window and then walks candidates in Python — one + query per type instead of one query per candidate. For a 24h window at + 15-minute steps that turns 96 round-trips into 3, which is the core of + the "SQL generate_series" optimization the plan called for. + + Set `with_bulk_modifications=True` to expand recurring events through + their bulk-modification continuation series. """ self._assert_initialized() if slot_step <= datetime.timedelta(0): @@ -637,19 +668,157 @@ def find_bookable_slots( raise CalendarGroupValidationError("duration must be a positive timedelta.") group = self._get_group_by_id(group_id) + slots = list(group.slots.all()) + if not slots: + return [] + + slot_pool_by_id: dict[int, set[int]] = { + s.id: set( + CalendarGroupSlotMembership.objects.filter_by_organization(self.organization.id) + .filter(slot_fk=s) + .values_list("calendar_fk_id", flat=True) + ) + for s in slots + } + required_count_by_slot_id = {s.id: s.required_count for s in slots} + + all_calendar_ids: set[int] = set() + for ids in slot_pool_by_id.values(): + all_calendar_ids.update(ids) + if not all_calendar_ids: + return [] + + managed_ids, unmanaged_ids = self._split_calendars_by_management(all_calendar_ids) + available_spans = self._fetch_available_spans( + managed_ids, search_window_start, search_window_end + ) + blocking_spans = self._fetch_blocking_spans( + unmanaged_ids, + search_window_start, + search_window_end, + with_bulk_modifications=with_bulk_modifications, + ) proposals: list[BookableSlotProposal] = [] cursor = search_window_start while cursor + duration <= search_window_end: window_start = cursor window_end = cursor + duration - is_bookable = ( - CalendarGroup.objects.filter_by_organization(self.organization.id) - .filter(id=group.id) - .only_groups_bookable_in_ranges([(window_start, window_end)]) - .exists() - ) - if is_bookable: + + all_slots_satisfied = True + for slot_id, pool_ids in slot_pool_by_id.items(): + available_count = 0 + for cid in pool_ids: + if cid in managed_ids: + # Managed: needs an AvailableTime that covers the window. + if any( + av_start <= window_start and av_end >= window_end + for av_start, av_end in available_spans.get(cid, ()) + ): + available_count += 1 + else: + # Unmanaged: must not overlap any blocking span. + if not any( + _intervals_overlap((bs, be), (window_start, window_end)) + for bs, be in blocking_spans.get(cid, ()) + ): + available_count += 1 + if available_count < required_count_by_slot_id[slot_id]: + all_slots_satisfied = False + break + if all_slots_satisfied: proposals.append(BookableSlotProposal(start_time=window_start, end_time=window_end)) cursor = cursor + slot_step return proposals + + def _split_calendars_by_management(self, calendar_ids: set[int]) -> tuple[set[int], set[int]]: + managed_ids: set[int] = set() + unmanaged_ids: set[int] = set() + for cid, managed in ( + Calendar.objects.filter_by_organization(self.organization.id) + .filter(id__in=calendar_ids) + .values_list("id", "manage_available_windows") + ): + if managed: + managed_ids.add(cid) + else: + unmanaged_ids.add(cid) + return managed_ids, unmanaged_ids + + def _fetch_available_spans( + self, + managed_ids: set[int], + search_window_start: datetime.datetime, + search_window_end: datetime.datetime, + ) -> dict[int, list[tuple[datetime.datetime, datetime.datetime]]]: + spans: dict[int, list[tuple[datetime.datetime, datetime.datetime]]] = {} + if not managed_ids: + return spans + for row in ( + AvailableTime.objects.filter_by_organization(self.organization.id) + .filter( + calendar_fk_id__in=managed_ids, + start_time__lte=search_window_end, + end_time__gte=search_window_start, + ) + .values("calendar_fk_id", "start_time", "end_time") + ): + spans.setdefault(row["calendar_fk_id"], []).append((row["start_time"], row["end_time"])) + return spans + + def _fetch_blocking_spans( + self, + unmanaged_ids: set[int], + search_window_start: datetime.datetime, + search_window_end: datetime.datetime, + *, + with_bulk_modifications: bool, + ) -> dict[int, list[tuple[datetime.datetime, datetime.datetime]]]: + spans: dict[int, list[tuple[datetime.datetime, datetime.datetime]]] = {} + if not unmanaged_ids: + return spans + + if with_bulk_modifications: + events_qs = CalendarEvent.objects.filter_by_organization( + self.organization.id + ).annotate_recurring_occurrences_with_bulk_modifications_on_date_range( + search_window_start, search_window_end + ) + else: + events_qs = CalendarEvent.objects.filter_by_organization( + self.organization.id + ).annotate_recurring_occurrences_on_date_range(search_window_start, search_window_end) + + overlap_filter = ( + Q(start_time__range=(search_window_start, search_window_end)) + | Q(end_time__range=(search_window_start, search_window_end)) + | Q(start_time__lte=search_window_start, end_time__gte=search_window_end) + | Q(recurring_occurrences__len__gt=0) + ) + + for ev in events_qs.filter(overlap_filter, calendar_fk_id__in=unmanaged_ids).values( + "calendar_fk_id", "start_time", "end_time", "recurring_occurrences" + ): + bucket = spans.setdefault(ev["calendar_fk_id"], []) + if ev["start_time"] and ev["end_time"]: + bucket.append((ev["start_time"], ev["end_time"])) + for occ in ev["recurring_occurrences"] or (): + occ_start = datetime.datetime.fromisoformat(occ["start_time"]) + occ_end = datetime.datetime.fromisoformat(occ["end_time"]) + bucket.append((occ_start, occ_end)) + + for bt in ( + BlockedTime.objects.filter_by_organization(self.organization.id) + .filter( + Q(start_time__range=(search_window_start, search_window_end)) + | Q(end_time__range=(search_window_start, search_window_end)) + | Q( + start_time__lte=search_window_start, + end_time__gte=search_window_end, + ), + calendar_fk_id__in=unmanaged_ids, + ) + .values("calendar_fk_id", "start_time", "end_time") + ): + spans.setdefault(bt["calendar_fk_id"], []).append((bt["start_time"], bt["end_time"])) + return spans diff --git a/calendar_integration/services/calendar_permission_service.py b/calendar_integration/services/calendar_permission_service.py index 06e375a..8107901 100644 --- a/calendar_integration/services/calendar_permission_service.py +++ b/calendar_integration/services/calendar_permission_service.py @@ -9,7 +9,9 @@ PermissionServiceInitializationError, ) from calendar_integration.models import ( + CalendarGroup, CalendarManagementToken, + CalendarOwnership, EventManagementPermissions, ) from calendar_integration.services.dataclasses import ( @@ -339,6 +341,24 @@ def can_perform_scheduling( return False + def can_manage_calendar_group(self, user: User, group: CalendarGroup) -> bool: + """Return True if `user` may create/update/delete `group` and create + events against it. + + Current rule: the user must own at least one calendar inside the group's + slot pools, within the same organization. An org-admin override would + slot in here once an explicit admin role is introduced on + `OrganizationMembership`; until then, ownership is the only signal. + """ + return ( + CalendarOwnership.objects.filter_by_organization(group.organization_id) + .filter( + user=user, + calendar_fk__group_slots__group_fk=group, + ) + .exists() + ) + def create_calendar_owner_token( self, organization_id: int, diff --git a/calendar_integration/tests/test_calendar_group_pr6.py b/calendar_integration/tests/test_calendar_group_pr6.py new file mode 100644 index 0000000..e3ec5ab --- /dev/null +++ b/calendar_integration/tests/test_calendar_group_pr6.py @@ -0,0 +1,322 @@ +"""Tests for PR6 follow-ups: bulk-modification parity, batched +`find_bookable_slots`, and `CalendarPermissionService.can_manage_calendar_group`.""" + +from datetime import timedelta +from unittest.mock import Mock + +from django.utils import timezone + +import pytest + +from calendar_integration.constants import CalendarProvider, CalendarType +from calendar_integration.models import ( + AvailableTime, + Calendar, + CalendarEvent, + CalendarGroup, + CalendarGroupSlot, + CalendarGroupSlotMembership, + CalendarOwnership, +) +from calendar_integration.permissions import CalendarGroupPermission +from calendar_integration.services.calendar_group_service import CalendarGroupService +from calendar_integration.services.calendar_permission_service import ( + CalendarPermissionService, +) +from organizations.models import Organization, OrganizationMembership +from users.models import User + + +@pytest.fixture +def organization(db): + return Organization.objects.create(name="Clinic Org", should_sync_rooms=False) + + +@pytest.fixture +def other_org(db): + return Organization.objects.create(name="Other", should_sync_rooms=False) + + +@pytest.fixture +def managed_calendars(organization): + calendars = {} + for name, external in ( + ("Dr. A", "phys_a"), + ("Dr. B", "phys_b"), + ("Room 1", "room_1"), + ): + calendars[external] = Calendar.objects.create( + organization=organization, + name=name, + external_id=external, + provider=CalendarProvider.GOOGLE, + calendar_type=( + CalendarType.PERSONAL if external.startswith("phys_") else CalendarType.RESOURCE + ), + manage_available_windows=True, + ) + return calendars + + +@pytest.fixture +def clinic_group(organization, managed_calendars): + group = CalendarGroup.objects.create(organization=organization, name="Clinic") + physicians = CalendarGroupSlot.objects.create( + organization=organization, group=group, name="Physicians", order=0 + ) + rooms = CalendarGroupSlot.objects.create( + organization=organization, group=group, name="Rooms", order=1 + ) + for cal in (managed_calendars["phys_a"], managed_calendars["phys_b"]): + CalendarGroupSlotMembership.objects.create( + organization=organization, slot=physicians, calendar=cal + ) + CalendarGroupSlotMembership.objects.create( + organization=organization, slot=rooms, calendar=managed_calendars["room_1"] + ) + return group + + +@pytest.fixture +def service(organization): + svc = CalendarGroupService() + svc.initialize(organization=organization) + return svc + + +def _seed_availability(calendars, start, end): + for cal in calendars: + AvailableTime.objects.create( + organization=cal.organization, + calendar=cal, + start_time_tz_unaware=start, + end_time_tz_unaware=end, + timezone="UTC", + ) + + +# --------------------------------------------------------------------------- +# bulk-modification parity +# --------------------------------------------------------------------------- +@pytest.mark.django_db +def test_only_groups_bookable_in_ranges_with_bulk_modifications_runs( + organization, clinic_group, managed_calendars +): + """Smoke test: the bulk-mods variant returns the same group as the non-bulk + variant when no bulk modifications exist on events or blocked times.""" + now = timezone.now().replace(microsecond=0) + start = now + timedelta(hours=1) + end = start + timedelta(hours=1) + _seed_availability(managed_calendars.values(), start, end) + + without = list( + CalendarGroup.objects.filter_by_organization( + organization.id + ).only_groups_bookable_in_ranges([(start, end)]) + ) + with_bulk = list( + CalendarGroup.objects.filter_by_organization( + organization.id + ).only_groups_bookable_in_ranges_with_bulk_modifications([(start, end)]) + ) + assert [g.id for g in without] == [g.id for g in with_bulk] == [clinic_group.id] + + +@pytest.mark.django_db +def test_check_group_availability_with_bulk_modifications_flag( + service, clinic_group, managed_calendars +): + now = timezone.now().replace(microsecond=0) + start = now + timedelta(hours=1) + end = start + timedelta(hours=1) + _seed_availability(managed_calendars.values(), start, end) + + result_default = service.check_group_availability( + group_id=clinic_group.id, ranges=[(start, end)] + ) + result_bulk = service.check_group_availability( + group_id=clinic_group.id, + ranges=[(start, end)], + with_bulk_modifications=True, + ) + # Same shape/contents when no bulk modifications exist. + assert [s.slot_id for s in result_default[0].slots] == [s.slot_id for s in result_bulk[0].slots] + for default_slot, bulk_slot in zip(result_default[0].slots, result_bulk[0].slots, strict=False): + assert default_slot.available_calendar_ids == bulk_slot.available_calendar_ids + + +# --------------------------------------------------------------------------- +# Batched `find_bookable_slots` +# --------------------------------------------------------------------------- +@pytest.mark.django_db +def test_find_bookable_slots_batched_equals_previous_behavior( + service, clinic_group, managed_calendars +): + now = timezone.now().replace(microsecond=0) + start = now + timedelta(hours=1) + # Managed calendars need AvailableTime for each candidate window; make a + # single span covering the whole search window. + _seed_availability(managed_calendars.values(), start, start + timedelta(hours=2)) + proposals = service.find_bookable_slots( + group_id=clinic_group.id, + search_window_start=start, + search_window_end=start + timedelta(hours=2), + duration=timedelta(minutes=30), + slot_step=timedelta(minutes=30), + ) + # 4 candidate windows fit within 2h at 30min step; all should be bookable. + assert [(p.start_time, p.end_time) for p in proposals] == [ + (start + timedelta(minutes=i * 30), start + timedelta(minutes=i * 30 + 30)) + for i in range(4) + ] + + +@pytest.mark.django_db +def test_find_bookable_slots_respects_unmanaged_blocking( + service, organization, managed_calendars, clinic_group +): + """Turn room_1 into an unmanaged calendar with a conflicting event mid-window. + Candidate windows overlapping that event should be excluded, others kept.""" + room = managed_calendars["room_1"] + room.manage_available_windows = False + room.save(update_fields=["manage_available_windows"]) + + # physicians remain managed and have availability across the whole window + now = timezone.now().replace(microsecond=0) + start = now + timedelta(hours=1) + _seed_availability( + [managed_calendars["phys_a"], managed_calendars["phys_b"]], + start, + start + timedelta(hours=2), + ) + # Room's conflict lives in the 2nd 30-min slot. + CalendarEvent.objects.create( + organization=organization, + calendar_fk=room, + title="Room booked", + external_id="ev_room", + start_time_tz_unaware=start + timedelta(minutes=30), + end_time_tz_unaware=start + timedelta(minutes=60), + timezone="UTC", + ) + + proposals = service.find_bookable_slots( + group_id=clinic_group.id, + search_window_start=start, + search_window_end=start + timedelta(hours=2), + duration=timedelta(minutes=30), + slot_step=timedelta(minutes=30), + ) + # Windows [0..30) and [60..90) and [90..120) are free; + # [30..60) overlaps the room event → excluded. + observed = [(p.start_time, p.end_time) for p in proposals] + assert (start, start + timedelta(minutes=30)) in observed + assert (start + timedelta(minutes=30), start + timedelta(minutes=60)) not in observed + assert (start + timedelta(minutes=60), start + timedelta(minutes=90)) in observed + + +@pytest.mark.django_db +def test_find_bookable_slots_empty_when_a_slot_has_no_calendars_in_pool(service, organization): + empty_group = CalendarGroup.objects.create(organization=organization, name="Empty") + CalendarGroupSlot.objects.create(organization=organization, group=empty_group, name="Nobody") + now = timezone.now().replace(microsecond=0) + proposals = service.find_bookable_slots( + group_id=empty_group.id, + search_window_start=now, + search_window_end=now + timedelta(hours=1), + duration=timedelta(minutes=30), + slot_step=timedelta(minutes=30), + ) + assert proposals == [] + + +@pytest.mark.django_db +def test_find_bookable_slots_single_query_per_type( + service, clinic_group, managed_calendars, django_assert_max_num_queries +): + """The batched implementation should issue a bounded number of queries + regardless of candidate count — the key win of the optimization. We + scan a window with many candidates and assert the count stays small. + """ + now = timezone.now().replace(microsecond=0) + start = now + timedelta(hours=1) + _seed_availability(managed_calendars.values(), start, start + timedelta(hours=6)) + # 6h at 15min step = 24 candidates. Pre-optimization this was ~24 round-trips. + # The batched implementation should use a small constant count instead. + with django_assert_max_num_queries(8): + service.find_bookable_slots( + group_id=clinic_group.id, + search_window_start=start, + search_window_end=start + timedelta(hours=6), + duration=timedelta(minutes=30), + slot_step=timedelta(minutes=15), + ) + + +# --------------------------------------------------------------------------- +# can_manage_calendar_group +# --------------------------------------------------------------------------- +@pytest.mark.django_db +def test_can_manage_calendar_group_true_for_owner(organization, clinic_group, managed_calendars): + owner = User.objects.create_user(email="owner@example.com") + CalendarOwnership.objects.create( + organization=organization, calendar=managed_calendars["phys_a"], user=owner + ) + svc = CalendarPermissionService() + assert svc.can_manage_calendar_group(user=owner, group=clinic_group) is True + + +@pytest.mark.django_db +def test_can_manage_calendar_group_false_for_non_owner(organization, clinic_group): + stranger = User.objects.create_user(email="stranger@example.com") + svc = CalendarPermissionService() + assert svc.can_manage_calendar_group(user=stranger, group=clinic_group) is False + + +@pytest.mark.django_db +def test_can_manage_calendar_group_scoped_to_org(organization, other_org, clinic_group): + # Owner of an *other-org* calendar doesn't pass. + user = User.objects.create_user(email="xorg@example.com") + other_calendar = Calendar.objects.create( + organization=other_org, name="X", external_id="x", provider=CalendarProvider.INTERNAL + ) + CalendarOwnership.objects.create(organization=other_org, calendar=other_calendar, user=user) + svc = CalendarPermissionService() + assert svc.can_manage_calendar_group(user=user, group=clinic_group) is False + + +# --------------------------------------------------------------------------- +# CalendarGroupPermission now delegates to CalendarPermissionService +# --------------------------------------------------------------------------- +@pytest.mark.django_db +def test_calendar_group_permission_delegates_to_permission_service( + organization, clinic_group, managed_calendars +): + owner = User.objects.create_user(email="delegate@example.com") + OrganizationMembership.objects.create(user=owner, organization=organization) + CalendarOwnership.objects.create( + organization=organization, calendar=managed_calendars["phys_a"], user=owner + ) + + perm = CalendarGroupPermission(calendar_permission_service=CalendarPermissionService()) + request = Mock() + request.user = owner + assert perm.has_permission(request, view=Mock()) is True + assert perm.has_object_permission(request, view=Mock(), obj=clinic_group) is True + + +@pytest.mark.django_db +def test_calendar_group_permission_falls_back_when_service_missing( + organization, clinic_group, managed_calendars +): + """If DI fails to wire the service we don't crash — the permission falls + back to the inline ownership check and still makes a correct decision.""" + owner = User.objects.create_user(email="fallback@example.com") + OrganizationMembership.objects.create(user=owner, organization=organization) + CalendarOwnership.objects.create( + organization=organization, calendar=managed_calendars["phys_a"], user=owner + ) + perm = CalendarGroupPermission(calendar_permission_service=None) + request = Mock() + request.user = owner + assert perm.has_object_permission(request, view=Mock(), obj=clinic_group) is True