diff --git a/engine/apps/alerts/tasks/notify_ical_schedule_shift.py b/engine/apps/alerts/tasks/notify_ical_schedule_shift.py index 9ec0f7e6..1a261880 100644 --- a/engine/apps/alerts/tasks/notify_ical_schedule_shift.py +++ b/engine/apps/alerts/tasks/notify_ical_schedule_shift.py @@ -9,9 +9,9 @@ from django.utils import timezone from apps.schedules.ical_events import ical_events from apps.schedules.ical_utils import ( calculate_shift_diff, + event_start_end_all_day_with_respect_to_type, get_icalendar_tz_or_utc, get_usernames_from_ical_event, - ical_date_to_datetime, is_icals_equal, memoized_users_in_ical, ) @@ -35,12 +35,7 @@ def get_current_shifts_from_ical(calendar, schedule, min_priority=0): usernames, priority = get_usernames_from_ical_event(event) users = memoized_users_in_ical(tuple(usernames), schedule.organization) if len(users) > 0: - event_start, start_all_day = ical_date_to_datetime( - event["DTSTART"].dt, - calendar_tz, - start=True, - ) - event_end, end_all_day = ical_date_to_datetime(event["DTEND"].dt, calendar_tz, start=False) + event_start, event_end, all_day_event = event_start_end_all_day_with_respect_to_type(event, calendar_tz) if event["UID"] in shifts: existing_event = shifts[event["UID"]] @@ -50,7 +45,7 @@ def get_current_shifts_from_ical(calendar, schedule, min_priority=0): "users": [u.pk for u in users], "start": event_start, "end": event_end, - "all_day": start_all_day, + "all_day": all_day_event, "priority": priority + min_priority, # increase priority for overrides "priority_increased_by": min_priority, } @@ -70,19 +65,14 @@ def get_next_shifts_from_ical(calendar, schedule, min_priority=0, days_to_lookup usernames, priority = get_usernames_from_ical_event(event) users = memoized_users_in_ical(tuple(usernames), schedule.organization) if len(users) > 0: - event_start, start_all_day = ical_date_to_datetime( - event["DTSTART"].dt, - calendar_tz, - start=True, - ) - event_end, end_all_day = ical_date_to_datetime(event["DTEND"].dt, calendar_tz, start=False) + event_start, event_end, all_day_event = event_start_end_all_day_with_respect_to_type(event, calendar_tz) # next_shifts are not stored in db so we can use User objects directly shifts[f"{event_start.timestamp()}_{event['UID']}"] = { "users": users, "start": event_start, "end": event_end, - "all_day": start_all_day, + "all_day": all_day_event, "priority": priority + min_priority, # increase priority for overrides "priority_increased_by": min_priority, } @@ -265,7 +255,7 @@ def notify_ical_schedule_shift(schedule_pk): for prev_ical_file, current_ical_file in prev_and_current_ical_files: if prev_ical_file is not None and ( - current_ical_file is None or not is_icals_equal(current_ical_file, prev_ical_file, schedule) + current_ical_file is None or not is_icals_equal(current_ical_file, prev_ical_file) ): # If icals are not equal then compare current_events from them is_prev_ical_diff = True diff --git a/engine/apps/api/tests/test_oncall_shift.py b/engine/apps/api/tests/test_oncall_shift.py index 0bdb093b..ddd21498 100644 --- a/engine/apps/api/tests/test_oncall_shift.py +++ b/engine/apps/api/tests/test_oncall_shift.py @@ -1517,7 +1517,7 @@ def test_on_call_shift_preview_update( now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) start_date = now - timezone.timedelta(days=7) - request_date = start_date + tomorrow = now + timezone.timedelta(days=1) user = make_user_for_organization(organization) other_user = make_user_for_organization(organization) @@ -1536,11 +1536,9 @@ def test_on_call_shift_preview_update( ) on_call_shift.add_rolling_users([[user]]) - url = "{}?date={}&days={}".format( - reverse("api-internal:oncall_shifts-preview"), request_date.strftime("%Y-%m-%d"), 1 - ) - shift_start = (start_date + timezone.timedelta(hours=10)).strftime("%Y-%m-%dT%H:%M:%SZ") - shift_end = (start_date + timezone.timedelta(hours=18)).strftime("%Y-%m-%dT%H:%M:%SZ") + url = "{}?date={}&days={}".format(reverse("api-internal:oncall_shifts-preview"), tomorrow.strftime("%Y-%m-%d"), 1) + shift_start = (tomorrow + timezone.timedelta(hours=10)).strftime("%Y-%m-%dT%H:%M:%SZ") + shift_end = (tomorrow + timezone.timedelta(hours=18)).strftime("%Y-%m-%dT%H:%M:%SZ") shift_data = { "schedule": schedule.public_primary_key, "shift_pk": on_call_shift.public_primary_key, @@ -1557,39 +1555,43 @@ def test_on_call_shift_preview_update( # check rotation events rotation_events = response.json()["rotation"] + assert len(rotation_events) == 4 + # the final original rotation events are returned and the ID is kept + for shift in rotation_events[:3]: + assert shift["shift"]["pk"] == on_call_shift.public_primary_key # previewing an update does not reuse shift PK if rotation already started - shift_pk = rotation_events[0]["shift"]["pk"] - assert shift_pk != on_call_shift.public_primary_key - expected_rotation_events = [ - { - "calendar_type": OnCallSchedule.TYPE_ICAL_PRIMARY, - "shift": {"pk": shift_pk}, - "start": shift_start, - "end": shift_end, - "all_day": False, - "is_override": False, - "is_empty": False, - "is_gap": False, - "priority_level": 1, - "missing_users": [], - "users": [{"display_name": other_user.username, "pk": other_user.public_primary_key}], - "source": "web", - }, - ] - assert rotation_events == expected_rotation_events + new_shift_pk = rotation_events[-1]["shift"]["pk"] + assert new_shift_pk != on_call_shift.public_primary_key + expected_shift_preview = { + "calendar_type": OnCallSchedule.TYPE_ICAL_PRIMARY, + "shift": {"pk": new_shift_pk}, + "start": shift_start, + "end": shift_end, + "all_day": False, + "is_override": False, + "is_empty": False, + "is_gap": False, + "priority_level": 1, + "missing_users": [], + "users": [{"display_name": other_user.username, "pk": other_user.public_primary_key}], + "source": "web", + } + assert rotation_events[-1] == expected_shift_preview # check final schedule events final_events = response.json()["final"] expected = ( # start (h), duration (H), user, priority + (0, 1, user.username, 1), # 0-1 user + (4, 1, user.username, 1), # 4-5 user (8, 1, user.username, 1), # 8-9 user (10, 8, other_user.username, 1), # 10-18 other_user ) expected_events = [ { - "end": (start_date + timezone.timedelta(hours=start + duration)).strftime("%Y-%m-%dT%H:%M:%SZ"), + "end": (tomorrow + timezone.timedelta(hours=start + duration)).strftime("%Y-%m-%dT%H:%M:%SZ"), "priority_level": priority, - "start": (start_date + timezone.timedelta(hours=start, milliseconds=1 if start == 0 else 0)).strftime( + "start": (tomorrow + timezone.timedelta(hours=start, milliseconds=1 if start == 0 else 0)).strftime( "%Y-%m-%dT%H:%M:%SZ" ), "user": user, diff --git a/engine/apps/api/views/schedule.py b/engine/apps/api/views/schedule.py index 2daf1120..ece5186c 100644 --- a/engine/apps/api/views/schedule.py +++ b/engine/apps/api/views/schedule.py @@ -8,6 +8,7 @@ from django.utils.functional import cached_property from rest_framework import status from rest_framework.decorators import action from rest_framework.exceptions import NotFound +from rest_framework.filters import SearchFilter from rest_framework.permissions import IsAuthenticated from rest_framework.views import Response from rest_framework.viewsets import ModelViewSet @@ -63,6 +64,8 @@ class ScheduleView( "related_escalation_chains", ), } + filter_backends = [SearchFilter] + search_fields = ("name",) queryset = OnCallSchedule.objects.all() serializer_class = PolymorphicScheduleSerializer diff --git a/engine/apps/schedules/ical_utils.py b/engine/apps/schedules/ical_utils.py index 8055558f..12fb5bd1 100644 --- a/engine/apps/schedules/ical_utils.py +++ b/engine/apps/schedules/ical_utils.py @@ -259,15 +259,10 @@ def list_of_empty_shifts_in_schedule(schedule, start_date, end_date): checked_events.add(event_hash) - all_day = False - if type(event[ICAL_DATETIME_START].dt) == datetime.date: - # Convert all-day events start and end from date to datetime with calendar's tz - start, _ = ical_date_to_datetime(event["DTSTART"].dt, calendar_tz, start=True) - end, _ = ical_date_to_datetime(event["DTEND"].dt, calendar_tz, start=False) - all_day = True - else: - start = event[ICAL_DATETIME_START].dt.astimezone(pytz.UTC) - end = event[ICAL_DATETIME_END].dt.astimezone(pytz.UTC) + start, end, all_day = event_start_end_all_day_with_respect_to_type(event, calendar_tz) + if not all_day: + start = start.astimezone(pytz.UTC) + end = end.astimezone(pytz.UTC) empty_shifts_per_calendar.append( EmptyShift( @@ -428,28 +423,32 @@ def is_icals_equal_line_by_line(first, second): return True -def is_icals_equal(first, second, schedule): - from apps.schedules.models import OnCallScheduleICal # noqa - - if isinstance(schedule, OnCallScheduleICal): - first_cal = Calendar.from_ical(first) +def is_icals_equal(first, second): + first_cal = Calendar.from_ical(first) + if first_cal.get("PRODID", None) in ("-//My calendar product//amixr//", "-//web schedule//oncall//"): + # Compare schedules generated by oncall line by line, since they not support SEQUENCE field yet. + # But we are sure that same calendars will have same lines, since we are generating it. + return is_icals_equal_line_by_line(first, second) + else: + # Compare external calendars by events, since sometimes they contain different lines even for equal calendars. second_cal = Calendar.from_ical(second) first_subcomponents = first_cal.subcomponents second_subcomponents = second_cal.subcomponents if len(first_subcomponents) != len(second_subcomponents): return False - for idx, first_cmp in enumerate(first_cal.subcomponents): - second_cmp = second_subcomponents[idx] - if first_cmp.name == second_cmp.name == "VEVENT": - first_uid, first_seq = first_cmp.get("UID", None), first_cmp.get("SEQUENCE", None) - second_uid, second_seq = second_cmp.get("UID", None), second_cmp.get("SEQUENCE", None) - if first_uid != second_uid: - return False - elif first_seq != second_seq: - return False + first_cal_events = {} + second_cal_events = {} + for cmp in first_cal.subcomponents: + first_cal_events[cmp.get("UID", None)] = cmp.get("SEQUENCE", None) + for cmp in second_cal.subcomponents: + second_cal_events[cmp.get("UID", None)] = cmp.get("SEQUENCE", None) + for first_uid, first_seq in first_cal_events.items(): + if first_uid not in second_cal_events: + return False + second_seq = second_cal_events.get(first_uid, None) + if first_seq != second_seq: + return False return True - else: - return is_icals_equal_line_by_line(first, second) def ical_date_to_datetime(date, tz, start): @@ -578,7 +577,7 @@ def list_of_gaps_in_schedule(schedule, start_date, end_date): end_datetime, ) for event in events: - start, end = start_end_with_respect_to_all_day(event, calendar_tz) + start, end, _ = event_start_end_all_day_with_respect_to_type(event, calendar_tz) intervals.append(DatetimeInterval(start, end)) return detect_gaps(intervals, start_datetime, end_datetime) @@ -615,6 +614,16 @@ def start_end_with_respect_to_all_day(event, calendar_tz): return start, end +def event_start_end_all_day_with_respect_to_type(event, calendar_tz): + all_day = False + if type(event[ICAL_DATETIME_START].dt) == datetime.date: + start, end = start_end_with_respect_to_all_day(event, calendar_tz) + all_day = True + else: + start, end = ical_events.get_start_and_end_with_respect_to_event_type(event) + return start, end, all_day + + def convert_windows_timezone_to_iana(tz_name): """ Conversion info taken from https://raw.githubusercontent.com/unicode-org/cldr/main/common/supplemental/windowsZones.xml diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index 08596c2d..5af43797 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -676,6 +676,9 @@ class OnCallScheduleWeb(OnCallSchedule): pass else: if update_shift.event_is_started: + custom_shift.rotation_start = max( + custom_shift.rotation_start, timezone.now().replace(microsecond=0) + ) custom_shift.start_rotation_from_user_index = update_shift.start_rotation_from_user_index update_shift.until = custom_shift.rotation_start extra_shifts.append(update_shift) @@ -692,7 +695,9 @@ class OnCallScheduleWeb(OnCallSchedule): # filter events using a temporal overriden calendar including the not-yet-saved shift events = self.filter_events(user_tz, starting_date, days=days, with_empty=True, with_gap=True) - shift_events = [e for e in events if e["shift"]["pk"] == custom_shift.public_primary_key] + # return preview events for affected shifts + updated_shift_pks = {s.public_primary_key for s in extra_shifts} + shift_events = [e for e in events if e["shift"]["pk"] in updated_shift_pks] final_events = self._resolve_schedule(events) _invalidate_cache(self, ical_property) diff --git a/engine/apps/schedules/tasks/refresh_ical_files.py b/engine/apps/schedules/tasks/refresh_ical_files.py index cad0baee..918564fb 100644 --- a/engine/apps/schedules/tasks/refresh_ical_files.py +++ b/engine/apps/schedules/tasks/refresh_ical_files.py @@ -47,7 +47,8 @@ def refresh_ical_file(schedule_pk): task_logger.info(f"run_task_primary {schedule_pk} {run_task_primary} prev_ical_file_primary is None") else: run_task_primary = not is_icals_equal( - schedule.cached_ical_file_primary, schedule.prev_ical_file_primary, schedule + schedule.cached_ical_file_primary, + schedule.prev_ical_file_primary, ) task_logger.info(f"run_task_primary {schedule_pk} {run_task_primary} icals not equal") run_task_overrides = False @@ -57,7 +58,8 @@ def refresh_ical_file(schedule_pk): task_logger.info(f"run_task_overrides {schedule_pk} {run_task_primary} prev_ical_file_overrides is None") else: run_task_overrides = not is_icals_equal( - schedule.cached_ical_file_overrides, schedule.prev_ical_file_overrides, schedule + schedule.cached_ical_file_overrides, + schedule.prev_ical_file_overrides, ) task_logger.info(f"run_task_overrides {schedule_pk} {run_task_primary} icals not equal") run_task = run_task_primary or run_task_overrides