From a0efa4e025ce5d81910de642018a42c2158bd5f2 Mon Sep 17 00:00:00 2001 From: Julia Date: Tue, 16 Aug 2022 15:38:52 +0300 Subject: [PATCH 1/6] Improve update shift logic using rotation start and until dates --- engine/apps/schedules/constants.py | 14 ++++++++ .../amixr_recurring_ical_events_adapter.py | 18 +++++++++- engine/apps/schedules/ical_utils.py | 34 +++++++++++++------ .../schedules/models/custom_on_call_shift.py | 13 +++---- 4 files changed, 61 insertions(+), 18 deletions(-) create mode 100644 engine/apps/schedules/constants.py diff --git a/engine/apps/schedules/constants.py b/engine/apps/schedules/constants.py new file mode 100644 index 00000000..719aa0b2 --- /dev/null +++ b/engine/apps/schedules/constants.py @@ -0,0 +1,14 @@ +import re + +ICAL_DATETIME_START = "DTSTART" +ICAL_DATETIME_END = "DTEND" +ICAL_DATETIME_STAMP = "DTSTAMP" +ICAL_SUMMARY = "SUMMARY" +ICAL_DESCRIPTION = "DESCRIPTION" +ICAL_ATTENDEE = "ATTENDEE" +ICAL_UID = "UID" +ICAL_RRULE = "RRULE" +ICAL_UNTIL = "UNTIL" +RE_PRIORITY = re.compile(r"^\[L(\d)\]") +RE_EVENT_UID_V1 = re.compile(r"amixr-([\w\d-]+)-U(\d+)-E(\d+)-S(\d+)") +RE_EVENT_UID_V2 = re.compile(r"oncall-([\w\d-]+)-PK([\w\d]+)-U(\d+)-E(\d+)-S(\d+)") diff --git a/engine/apps/schedules/ical_events/adapter/amixr_recurring_ical_events_adapter.py b/engine/apps/schedules/ical_events/adapter/amixr_recurring_ical_events_adapter.py index 4d17f497..5ad36c26 100644 --- a/engine/apps/schedules/ical_events/adapter/amixr_recurring_ical_events_adapter.py +++ b/engine/apps/schedules/ical_events/adapter/amixr_recurring_ical_events_adapter.py @@ -6,6 +6,7 @@ from django.utils import timezone from icalendar import Calendar, Event from recurring_ical_events import UnfoldableCalendar, compare_greater, is_event, time_span_contains_event +from apps.schedules.constants import ICAL_DATETIME_END, ICAL_DATETIME_STAMP, ICAL_DATETIME_START, ICAL_RRULE, ICAL_UNTIL from apps.schedules.ical_events.proxy.ical_proxy import IcalService EXTRA_LOOKUP_DAYS = 16 @@ -19,6 +20,17 @@ class AmixrUnfoldableCalendar(UnfoldableCalendar): So i took part of code from 0.1.20b0 but leave 0.1.16b in requirements. """ + class RepeatedEvent(UnfoldableCalendar.RepeatedEvent): + class Repetition(UnfoldableCalendar.RepeatedEvent.Repetition): + """ + A repetition of an event. Overridden version of + recurring_ical_events.UnfoldableCalendar.RepeatedEvent.Repetition. This is overridden to remove the 'RRULE' + param from ATTRIBUTES_TO_DELETE_ON_COPY, because the 'UNTIL' param must be stored in repetition events to + calculate its end date. + """ + + ATTRIBUTES_TO_DELETE_ON_COPY = ["RDATE", "EXDATE"] + def between(self, start, stop): """Return events at a time between start (inclusive) and end (inclusive)""" span_start = self.to_datetime(start) @@ -83,6 +95,10 @@ class AmixrRecurringIcalEventsAdapter(IcalService): ) def filter_extra_days(event): - return time_span_contains_event(start_date, end_date, event["DTSTART"].dt, event["DTEND"].dt) + event_start = max(event[ICAL_DATETIME_START].dt, event[ICAL_DATETIME_STAMP].dt) + event_end = event[ICAL_DATETIME_END].dt + if event.get(ICAL_RRULE, {}).get(ICAL_UNTIL): + event_end = min(event[ICAL_RRULE][ICAL_UNTIL][0], event[ICAL_DATETIME_END].dt) + return time_span_contains_event(start_date, end_date, event_start, event_end) return list(filter(filter_extra_days, events)) diff --git a/engine/apps/schedules/ical_utils.py b/engine/apps/schedules/ical_utils.py index 93092cc3..1c88828e 100644 --- a/engine/apps/schedules/ical_utils.py +++ b/engine/apps/schedules/ical_utils.py @@ -13,6 +13,20 @@ from django.db.models import Q from django.utils import timezone from icalendar import Calendar +from apps.schedules.constants import ( + ICAL_ATTENDEE, + ICAL_DATETIME_END, + ICAL_DATETIME_STAMP, + ICAL_DATETIME_START, + ICAL_DESCRIPTION, + ICAL_RRULE, + ICAL_SUMMARY, + ICAL_UID, + ICAL_UNTIL, + RE_EVENT_UID_V1, + RE_EVENT_UID_V2, + RE_PRIORITY, +) from apps.schedules.ical_events import ical_events from common.constants.role import Role from common.utils import timed_lru_cache @@ -68,15 +82,6 @@ def memoized_users_in_ical(usernames_from_ical, organization): return users_in_ical(usernames_from_ical, organization) -ICAL_DATETIME_START = "DTSTART" -ICAL_DATETIME_END = "DTEND" -ICAL_SUMMARY = "SUMMARY" -ICAL_DESCRIPTION = "DESCRIPTION" -ICAL_ATTENDEE = "ATTENDEE" -ICAL_UID = "UID" -RE_PRIORITY = re.compile(r"^\[L(\d)\]") -RE_EVENT_UID_V1 = re.compile(r"amixr-([\w\d-]+)-U(\d+)-E(\d+)-S(\d+)") -RE_EVENT_UID_V2 = re.compile(r"oncall-([\w\d-]+)-PK([\w\d]+)-U(\d+)-E(\d+)-S(\d+)") logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) @@ -171,8 +176,10 @@ def get_shifts_dict(calendar, calendar_type, schedule, datetime_start, datetime_ # Define on-call shift out of ical event that has the actual user if len(users) > 0 or with_empty_shifts: if type(event[ICAL_DATETIME_START].dt) == datetime.date: - start = event[ICAL_DATETIME_START].dt + start = max(event[ICAL_DATETIME_START].dt, event[ICAL_DATETIME_STAMP].dt.date()) end = event[ICAL_DATETIME_END].dt + if event.get(ICAL_RRULE, {}).get(ICAL_UNTIL): + end = min(event[ICAL_DATETIME_END].dt, event[ICAL_RRULE][ICAL_UNTIL][0].date()) if start <= date < end: result_date.append( { @@ -187,8 +194,13 @@ def get_shifts_dict(calendar, calendar_type, schedule, datetime_start, datetime_ } ) else: - start = event[ICAL_DATETIME_START].dt.astimezone(pytz.UTC) + start = max( + event[ICAL_DATETIME_START].dt.astimezone(pytz.UTC), + event[ICAL_DATETIME_STAMP].dt.astimezone(pytz.UTC), + ) end = event[ICAL_DATETIME_END].dt.astimezone(pytz.UTC) + if event.get(ICAL_RRULE, {}).get(ICAL_UNTIL): + end = min(event[ICAL_DATETIME_END].dt.astimezone(pytz.UTC), event[ICAL_RRULE][ICAL_UNTIL][0]) result_datetime.append( { diff --git a/engine/apps/schedules/models/custom_on_call_shift.py b/engine/apps/schedules/models/custom_on_call_shift.py index fe5aa46c..92bc1e7c 100644 --- a/engine/apps/schedules/models/custom_on_call_shift.py +++ b/engine/apps/schedules/models/custom_on_call_shift.py @@ -280,7 +280,7 @@ class CustomOnCallShift(models.Model): # rolling_users shift converts to several ical events if self.type in (CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, CustomOnCallShift.TYPE_OVERRIDE): # generate initial iCal for counting rotation start date - event_ical = self.generate_ical(self.start, user_counter=0) + event_ical = self.generate_ical(self.start) rotations_created = 0 all_rotation_checked = False @@ -301,13 +301,14 @@ class CustomOnCallShift(models.Model): if not start: # means that rotation ends before next event starts all_rotation_checked = True break - elif start >= self.rotation_start: # event has already started, generate iCal for each user + elif start + self.duration > self.rotation_start: + # event has already started, generate iCal for each user for user_counter, user in enumerate(users, start=1): event_ical = self.generate_ical(start, user_counter, user, counter, time_zone) result += event_ical rotations_created += 1 else: # generate default iCal to calculate the date for the next rotation - event_ical = self.generate_ical(start, user_counter=0) + event_ical = self.generate_ical(start) if rotations_created == len(users_queue): # means that we generated iCal for every user group all_rotation_checked = True @@ -319,14 +320,14 @@ class CustomOnCallShift(models.Model): result += self.generate_ical(self.start, user_counter, user, time_zone=time_zone) return result - def generate_ical(self, start, user_counter, user=None, counter=1, time_zone="UTC"): + def generate_ical(self, start, user_counter=0, user=None, counter=1, time_zone="UTC"): event = Event() event["uid"] = f"oncall-{self.uuid}-PK{self.public_primary_key}-U{user_counter}-E{counter}-S{self.source}" if user: event.add("summary", self.get_summary_with_user_for_ical(user)) event.add("dtstart", self.convert_dt_to_schedule_timezone(start, time_zone)) event.add("dtend", self.convert_dt_to_schedule_timezone(start + self.duration, time_zone)) - event.add("dtstamp", timezone.now()) + event.add("dtstamp", self.rotation_start) if self.event_ical_rules: event.add("rrule", self.event_ical_rules) try: @@ -407,7 +408,7 @@ class CustomOnCallShift(models.Model): for event in ical_iter: if end_date: # end_date exists for long events with frequency weekly and monthly if end_date >= event.start >= next_event_start: - if event.start >= self.rotation_start: + if event.stop > self.rotation_start: next_event = event break else: From 1e3e39d5e027830b11f3fe0d73ce60f5e604d8b0 Mon Sep 17 00:00:00 2001 From: Julia Date: Wed, 17 Aug 2022 13:17:28 +0300 Subject: [PATCH 2/6] Add tests for events datetime calculation --- .../tests/test_custom_on_call_shift.py | 228 +++++++++++++++++- 1 file changed, 227 insertions(+), 1 deletion(-) diff --git a/engine/apps/schedules/tests/test_custom_on_call_shift.py b/engine/apps/schedules/tests/test_custom_on_call_shift.py index ab77dc1b..f62c7582 100644 --- a/engine/apps/schedules/tests/test_custom_on_call_shift.py +++ b/engine/apps/schedules/tests/test_custom_on_call_shift.py @@ -477,7 +477,7 @@ def test_rolling_users_with_diff_start_and_rotation_start_daily( "duration": timezone.timedelta(seconds=1800), "frequency": CustomOnCallShift.FREQUENCY_DAILY, "schedule": schedule, - "until": now + timezone.timedelta(days=6, minutes=1), + "until": now + timezone.timedelta(days=6, minutes=10), } rolling_users = [[user_1], [user_2], [user_3]] on_call_shift = make_on_call_shift( @@ -767,6 +767,232 @@ def test_rolling_users_with_diff_start_and_rotation_start_monthly_by_monthday( assert len(users_on_call) == 0 +@pytest.mark.django_db +def test_get_oncall_users_with_respect_to_rotation_start_and_until_dates_hourly( + make_organization_and_user, + make_on_call_shift, + make_schedule, +): + """Test calculation start and end event dates for one event with respect to rotation start and until""" + organization, user = make_organization_and_user() + + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + now = timezone.now().replace(microsecond=0) + + data = { + "priority_level": 1, + "start": now, + "rotation_start": now + timezone.timedelta(minutes=10), + "duration": timezone.timedelta(hours=1), + "frequency": CustomOnCallShift.FREQUENCY_HOURLY, + "schedule": schedule, + "until": now + timezone.timedelta(minutes=40), + } + rolling_users = [[user]] + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data + ) + on_call_shift.add_rolling_users(rolling_users) + + date = now + timezone.timedelta(minutes=2) + + user_on_call_dates = [date + timezone.timedelta(minutes=10), date + timezone.timedelta(minutes=35)] + nobody_on_call_dates = [ + date, # less than rotation start + date + timezone.timedelta(minutes=5), # less than rotation start + date + timezone.timedelta(minutes=40), # higher than until + ] + for dt in user_on_call_dates: + users_on_call = list_users_to_notify_from_ical(schedule, dt) + assert len(users_on_call) == 1 + assert user in users_on_call + + for dt in nobody_on_call_dates: + users_on_call = list_users_to_notify_from_ical(schedule, dt) + assert len(users_on_call) == 0 + + +@pytest.mark.django_db +def test_get_oncall_users_with_respect_to_rotation_start_and_until_dates_daily( + make_organization_and_user, + make_on_call_shift, + make_schedule, +): + """Test calculation start and end event dates for one event with respect to rotation start and until""" + organization, user = make_organization_and_user() + + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + now = timezone.now().replace(microsecond=0) + + data = { + "priority_level": 1, + "start": now, + "rotation_start": now + timezone.timedelta(hours=5), + "duration": timezone.timedelta(days=1), + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + "schedule": schedule, + "until": now + timezone.timedelta(hours=15), + } + rolling_users = [[user]] + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data + ) + on_call_shift.add_rolling_users(rolling_users) + + date = now + timezone.timedelta(minutes=5) + + user_on_call_dates = [date + timezone.timedelta(hours=5), date + timezone.timedelta(hours=10)] + nobody_on_call_dates = [ + date, # less than rotation start + date + timezone.timedelta(hours=4), # less than rotation start + date + timezone.timedelta(hours=15), # higher than until + ] + + for dt in user_on_call_dates: + users_on_call = list_users_to_notify_from_ical(schedule, dt) + assert len(users_on_call) == 1 + assert user in users_on_call + + for dt in nobody_on_call_dates: + users_on_call = list_users_to_notify_from_ical(schedule, dt) + assert len(users_on_call) == 0 + + +@pytest.mark.django_db +def test_get_oncall_users_with_respect_to_rotation_start_and_until_dates_weekly( + make_organization_and_user, + make_on_call_shift, + make_schedule, +): + """Test calculation start and end event dates for one event with respect to rotation start and until""" + organization, user = make_organization_and_user() + + # simple weekly event + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + now = timezone.now().replace(microsecond=0) + + data = { + "priority_level": 1, + "start": now, + "rotation_start": now + timezone.timedelta(days=1), + "duration": timezone.timedelta(days=7), + "frequency": CustomOnCallShift.FREQUENCY_WEEKLY, + "schedule": schedule, + "until": now + timezone.timedelta(days=6), + "week_start": now.weekday(), + } + rolling_users = [[user]] + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data + ) + on_call_shift.add_rolling_users(rolling_users) + + date = now + timezone.timedelta(minutes=5) + + user_on_call_dates = [date + timezone.timedelta(days=1), date + timezone.timedelta(days=5)] + nobody_on_call_dates = [ + date, # less than rotation start + date + timezone.timedelta(hours=23), # less than rotation start + date + timezone.timedelta(days=6), # higher than until + ] + + for dt in user_on_call_dates: + users_on_call = list_users_to_notify_from_ical(schedule, dt) + assert len(users_on_call) == 1 + assert user in users_on_call + + for dt in nobody_on_call_dates: + users_on_call = list_users_to_notify_from_ical(schedule, dt) + assert len(users_on_call) == 0 + + # weekly event with by_day + schedule_2 = make_schedule(organization, schedule_class=OnCallScheduleWeb) + today_weekday = now.weekday() + weekdays = [today_weekday, (today_weekday + 1) % 7, (today_weekday + 2) % 7, (today_weekday + 5) % 7] + by_day = [CustomOnCallShift.ICAL_WEEKDAY_MAP[day] for day in weekdays] + data = { + "priority_level": 1, + "start": now, + "rotation_start": now + timezone.timedelta(days=1), + "duration": timezone.timedelta(hours=12), + "frequency": CustomOnCallShift.FREQUENCY_WEEKLY, + "schedule": schedule_2, + "until": now + timezone.timedelta(days=4, hours=23), + "week_start": today_weekday, + "by_day": by_day, + } + on_call_shift_2 = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data + ) + on_call_shift_2.add_rolling_users(rolling_users) + + date = now + timezone.timedelta(minutes=5) + + user_on_call_dates = [date + timezone.timedelta(days=1), date + timezone.timedelta(days=2)] + nobody_on_call_dates = [ + date, # less than rotation start + date + timezone.timedelta(hours=23), # less than rotation start + date + timezone.timedelta(days=3), # out of by_day + date + timezone.timedelta(days=4), # out of by_day + date + timezone.timedelta(days=5), # higher than until + ] + + for dt in user_on_call_dates: + users_on_call = list_users_to_notify_from_ical(schedule_2, dt) + assert len(users_on_call) == 1 + assert user in users_on_call + + for dt in nobody_on_call_dates: + users_on_call = list_users_to_notify_from_ical(schedule_2, dt) + assert len(users_on_call) == 0 + + +@pytest.mark.django_db +def test_get_oncall_users_with_respect_to_rotation_start_and_until_dates_monthly( + make_organization_and_user, + make_on_call_shift, + make_schedule, +): + """Test calculation start and end event dates for one event with respect to rotation start and until""" + organization, user = make_organization_and_user() + + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + now = timezone.now().replace(microsecond=0) + + data = { + "priority_level": 1, + "start": now, + "rotation_start": now + timezone.timedelta(days=5), + "duration": timezone.timedelta(days=30), + "frequency": CustomOnCallShift.FREQUENCY_MONTHLY, + "schedule": schedule, + "until": now + timezone.timedelta(days=15), + } + rolling_users = [[user]] + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data + ) + on_call_shift.add_rolling_users(rolling_users) + + date = now + timezone.timedelta(minutes=5) + + user_on_call_dates = [date + timezone.timedelta(days=5), date + timezone.timedelta(days=10)] + nobody_on_call_dates = [ + date, # less than rotation start + date + timezone.timedelta(days=4), # less than rotation start + date + timezone.timedelta(days=15), # higher than until + ] + + for dt in user_on_call_dates: + users_on_call = list_users_to_notify_from_ical(schedule, dt) + assert len(users_on_call) == 1 + assert user in users_on_call + + for dt in nobody_on_call_dates: + users_on_call = list_users_to_notify_from_ical(schedule, dt) + assert len(users_on_call) == 0 + + @pytest.mark.django_db def test_get_oncall_users_for_empty_schedule( make_organization, From 5405af359843041ee015cca7998d714277535f6c Mon Sep 17 00:00:00 2001 From: Julia Date: Wed, 17 Aug 2022 13:54:56 +0300 Subject: [PATCH 3/6] Fix test --- engine/apps/schedules/tests/test_custom_on_call_shift.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/engine/apps/schedules/tests/test_custom_on_call_shift.py b/engine/apps/schedules/tests/test_custom_on_call_shift.py index f62c7582..c7967ca3 100644 --- a/engine/apps/schedules/tests/test_custom_on_call_shift.py +++ b/engine/apps/schedules/tests/test_custom_on_call_shift.py @@ -535,7 +535,7 @@ def test_rolling_users_with_diff_start_and_rotation_start_weekly( "duration": timezone.timedelta(seconds=1800), "frequency": CustomOnCallShift.FREQUENCY_WEEKLY, "schedule": schedule, - "until": now + timezone.timedelta(days=42, minutes=1), + "until": now + timezone.timedelta(days=42, minutes=10), } rolling_users = [[user_1], [user_2], [user_3]] on_call_shift = make_on_call_shift( From 736bc3b3485391a8c886b48459768b9d7f7e9895 Mon Sep 17 00:00:00 2001 From: Julia Date: Wed, 17 Aug 2022 17:04:27 +0300 Subject: [PATCH 4/6] Fix events dates calculation for old shifts and ical schedules --- .../amixr_recurring_ical_events_adapter.py | 27 +++++++++++++--- engine/apps/schedules/ical_utils.py | 31 ++++++++++++------- .../schedules/models/custom_on_call_shift.py | 8 +++-- .../tests/test_custom_on_call_shift.py | 5 +++ 4 files changed, 53 insertions(+), 18 deletions(-) diff --git a/engine/apps/schedules/ical_events/adapter/amixr_recurring_ical_events_adapter.py b/engine/apps/schedules/ical_events/adapter/amixr_recurring_ical_events_adapter.py index 5ad36c26..f1d74cff 100644 --- a/engine/apps/schedules/ical_events/adapter/amixr_recurring_ical_events_adapter.py +++ b/engine/apps/schedules/ical_events/adapter/amixr_recurring_ical_events_adapter.py @@ -2,11 +2,21 @@ from collections import defaultdict from datetime import datetime from typing import List +from django.apps import apps from django.utils import timezone from icalendar import Calendar, Event from recurring_ical_events import UnfoldableCalendar, compare_greater, is_event, time_span_contains_event -from apps.schedules.constants import ICAL_DATETIME_END, ICAL_DATETIME_STAMP, ICAL_DATETIME_START, ICAL_RRULE, ICAL_UNTIL +from apps.schedules.constants import ( + ICAL_DATETIME_END, + ICAL_DATETIME_STAMP, + ICAL_DATETIME_START, + ICAL_RRULE, + ICAL_UID, + ICAL_UNTIL, + RE_EVENT_UID_V1, + RE_EVENT_UID_V2, +) from apps.schedules.ical_events.proxy.ical_proxy import IcalService EXTRA_LOOKUP_DAYS = 16 @@ -95,10 +105,17 @@ class AmixrRecurringIcalEventsAdapter(IcalService): ) def filter_extra_days(event): - event_start = max(event[ICAL_DATETIME_START].dt, event[ICAL_DATETIME_STAMP].dt) - event_end = event[ICAL_DATETIME_END].dt - if event.get(ICAL_RRULE, {}).get(ICAL_UNTIL): - event_end = min(event[ICAL_RRULE][ICAL_UNTIL][0], event[ICAL_DATETIME_END].dt) + CustomOnCallShift = apps.get_model("schedules", "CustomOnCallShift") + match = RE_EVENT_UID_V2.match(event[ICAL_UID]) or RE_EVENT_UID_V1.match(event[ICAL_UID]) + # use different calculation rule for events from custom shifts generated at web + if match and int(match.groups()[-1]) == CustomOnCallShift.SOURCE_WEB: + event_start = max(event[ICAL_DATETIME_START].dt, event[ICAL_DATETIME_STAMP].dt) + event_end = event[ICAL_DATETIME_END].dt + if event.get(ICAL_RRULE, {}).get(ICAL_UNTIL): + event_end = min(event[ICAL_RRULE][ICAL_UNTIL][0], event[ICAL_DATETIME_END].dt) + else: + event_start = event[ICAL_DATETIME_START].dt + event_end = event[ICAL_DATETIME_END].dt return time_span_contains_event(start_date, end_date, event_start, event_end) return list(filter(filter_extra_days, events)) diff --git a/engine/apps/schedules/ical_utils.py b/engine/apps/schedules/ical_utils.py index 1c88828e..e44fa9ae 100644 --- a/engine/apps/schedules/ical_utils.py +++ b/engine/apps/schedules/ical_utils.py @@ -165,6 +165,7 @@ def list_of_oncall_shifts_from_ical( def get_shifts_dict(calendar, calendar_type, schedule, datetime_start, datetime_end, date, with_empty_shifts=False): + OnCallScheduleWeb = apps.get_model("schedules", "OnCallScheduleWeb") events = ical_events.get_events_from_ical_between(calendar, datetime_start, datetime_end) result_datetime = [] result_date = [] @@ -176,10 +177,14 @@ def get_shifts_dict(calendar, calendar_type, schedule, datetime_start, datetime_ # Define on-call shift out of ical event that has the actual user if len(users) > 0 or with_empty_shifts: if type(event[ICAL_DATETIME_START].dt) == datetime.date: - start = max(event[ICAL_DATETIME_START].dt, event[ICAL_DATETIME_STAMP].dt.date()) - end = event[ICAL_DATETIME_END].dt - if event.get(ICAL_RRULE, {}).get(ICAL_UNTIL): - end = min(event[ICAL_DATETIME_END].dt, event[ICAL_RRULE][ICAL_UNTIL][0].date()) + if isinstance(schedule, OnCallScheduleWeb): + start = max(event[ICAL_DATETIME_START].dt, event[ICAL_DATETIME_STAMP].dt.date()) + end = event[ICAL_DATETIME_END].dt + if event.get(ICAL_RRULE, {}).get(ICAL_UNTIL): + end = min(event[ICAL_DATETIME_END].dt, event[ICAL_RRULE][ICAL_UNTIL][0].date()) + else: + start = event[ICAL_DATETIME_START].dt + end = event[ICAL_DATETIME_END].dt if start <= date < end: result_date.append( { @@ -194,13 +199,17 @@ def get_shifts_dict(calendar, calendar_type, schedule, datetime_start, datetime_ } ) else: - start = max( - event[ICAL_DATETIME_START].dt.astimezone(pytz.UTC), - event[ICAL_DATETIME_STAMP].dt.astimezone(pytz.UTC), - ) - end = event[ICAL_DATETIME_END].dt.astimezone(pytz.UTC) - if event.get(ICAL_RRULE, {}).get(ICAL_UNTIL): - end = min(event[ICAL_DATETIME_END].dt.astimezone(pytz.UTC), event[ICAL_RRULE][ICAL_UNTIL][0]) + if isinstance(schedule, OnCallScheduleWeb): + start = max( + event[ICAL_DATETIME_START].dt.astimezone(pytz.UTC), + event[ICAL_DATETIME_STAMP].dt.astimezone(pytz.UTC), + ) + end = event[ICAL_DATETIME_END].dt.astimezone(pytz.UTC) + if event.get(ICAL_RRULE, {}).get(ICAL_UNTIL): + end = min(event[ICAL_DATETIME_END].dt.astimezone(pytz.UTC), event[ICAL_RRULE][ICAL_UNTIL][0]) + else: + start = event[ICAL_DATETIME_START].dt.astimezone(pytz.UTC) + end = event[ICAL_DATETIME_END].dt.astimezone(pytz.UTC) result_datetime.append( { diff --git a/engine/apps/schedules/models/custom_on_call_shift.py b/engine/apps/schedules/models/custom_on_call_shift.py index 92bc1e7c..3cc34397 100644 --- a/engine/apps/schedules/models/custom_on_call_shift.py +++ b/engine/apps/schedules/models/custom_on_call_shift.py @@ -301,7 +301,9 @@ class CustomOnCallShift(models.Model): if not start: # means that rotation ends before next event starts all_rotation_checked = True break - elif start + self.duration > self.rotation_start: + elif ( + self.source == CustomOnCallShift.SOURCE_WEB and start + self.duration > self.rotation_start + ) or start >= self.rotation_start: # event has already started, generate iCal for each user for user_counter, user in enumerate(users, start=1): event_ical = self.generate_ical(start, user_counter, user, counter, time_zone) @@ -408,7 +410,9 @@ class CustomOnCallShift(models.Model): for event in ical_iter: if end_date: # end_date exists for long events with frequency weekly and monthly if end_date >= event.start >= next_event_start: - if event.stop > self.rotation_start: + if ( + self.source == CustomOnCallShift.SOURCE_WEB and event.stop > self.rotation_start + ) or event.start >= self.rotation_start: next_event = event break else: diff --git a/engine/apps/schedules/tests/test_custom_on_call_shift.py b/engine/apps/schedules/tests/test_custom_on_call_shift.py index c7967ca3..45d516b6 100644 --- a/engine/apps/schedules/tests/test_custom_on_call_shift.py +++ b/engine/apps/schedules/tests/test_custom_on_call_shift.py @@ -787,6 +787,7 @@ def test_get_oncall_users_with_respect_to_rotation_start_and_until_dates_hourly( "frequency": CustomOnCallShift.FREQUENCY_HOURLY, "schedule": schedule, "until": now + timezone.timedelta(minutes=40), + "source": CustomOnCallShift.SOURCE_WEB, } rolling_users = [[user]] on_call_shift = make_on_call_shift( @@ -832,6 +833,7 @@ def test_get_oncall_users_with_respect_to_rotation_start_and_until_dates_daily( "frequency": CustomOnCallShift.FREQUENCY_DAILY, "schedule": schedule, "until": now + timezone.timedelta(hours=15), + "source": CustomOnCallShift.SOURCE_WEB, } rolling_users = [[user]] on_call_shift = make_on_call_shift( @@ -880,6 +882,7 @@ def test_get_oncall_users_with_respect_to_rotation_start_and_until_dates_weekly( "schedule": schedule, "until": now + timezone.timedelta(days=6), "week_start": now.weekday(), + "source": CustomOnCallShift.SOURCE_WEB, } rolling_users = [[user]] on_call_shift = make_on_call_shift( @@ -920,6 +923,7 @@ def test_get_oncall_users_with_respect_to_rotation_start_and_until_dates_weekly( "until": now + timezone.timedelta(days=4, hours=23), "week_start": today_weekday, "by_day": by_day, + "source": CustomOnCallShift.SOURCE_WEB, } on_call_shift_2 = make_on_call_shift( organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data @@ -967,6 +971,7 @@ def test_get_oncall_users_with_respect_to_rotation_start_and_until_dates_monthly "frequency": CustomOnCallShift.FREQUENCY_MONTHLY, "schedule": schedule, "until": now + timezone.timedelta(days=15), + "source": CustomOnCallShift.SOURCE_WEB, } rolling_users = [[user]] on_call_shift = make_on_call_shift( From bcf6ccb865a89e53cfc73de3a86576cdd349de65 Mon Sep 17 00:00:00 2001 From: Julia Date: Thu, 18 Aug 2022 13:55:07 +0300 Subject: [PATCH 5/6] Move calculation of event start and end dates to separate method --- .../amixr_recurring_ical_events_adapter.py | 24 +-------- engine/apps/schedules/ical_utils.py | 52 +++++++++++-------- 2 files changed, 31 insertions(+), 45 deletions(-) diff --git a/engine/apps/schedules/ical_events/adapter/amixr_recurring_ical_events_adapter.py b/engine/apps/schedules/ical_events/adapter/amixr_recurring_ical_events_adapter.py index f1d74cff..b5729313 100644 --- a/engine/apps/schedules/ical_events/adapter/amixr_recurring_ical_events_adapter.py +++ b/engine/apps/schedules/ical_events/adapter/amixr_recurring_ical_events_adapter.py @@ -2,22 +2,12 @@ from collections import defaultdict from datetime import datetime from typing import List -from django.apps import apps from django.utils import timezone from icalendar import Calendar, Event from recurring_ical_events import UnfoldableCalendar, compare_greater, is_event, time_span_contains_event -from apps.schedules.constants import ( - ICAL_DATETIME_END, - ICAL_DATETIME_STAMP, - ICAL_DATETIME_START, - ICAL_RRULE, - ICAL_UID, - ICAL_UNTIL, - RE_EVENT_UID_V1, - RE_EVENT_UID_V2, -) from apps.schedules.ical_events.proxy.ical_proxy import IcalService +from apps.schedules.ical_utils import get_start_and_end_with_respect_to_event_type EXTRA_LOOKUP_DAYS = 16 @@ -105,17 +95,7 @@ class AmixrRecurringIcalEventsAdapter(IcalService): ) def filter_extra_days(event): - CustomOnCallShift = apps.get_model("schedules", "CustomOnCallShift") - match = RE_EVENT_UID_V2.match(event[ICAL_UID]) or RE_EVENT_UID_V1.match(event[ICAL_UID]) - # use different calculation rule for events from custom shifts generated at web - if match and int(match.groups()[-1]) == CustomOnCallShift.SOURCE_WEB: - event_start = max(event[ICAL_DATETIME_START].dt, event[ICAL_DATETIME_STAMP].dt) - event_end = event[ICAL_DATETIME_END].dt - if event.get(ICAL_RRULE, {}).get(ICAL_UNTIL): - event_end = min(event[ICAL_RRULE][ICAL_UNTIL][0], event[ICAL_DATETIME_END].dt) - else: - event_start = event[ICAL_DATETIME_START].dt - event_end = event[ICAL_DATETIME_END].dt + event_start, event_end = get_start_and_end_with_respect_to_event_type(event) return time_span_contains_event(start_date, end_date, event_start, event_end) return list(filter(filter_extra_days, events)) diff --git a/engine/apps/schedules/ical_utils.py b/engine/apps/schedules/ical_utils.py index e44fa9ae..49206b8b 100644 --- a/engine/apps/schedules/ical_utils.py +++ b/engine/apps/schedules/ical_utils.py @@ -165,7 +165,6 @@ def list_of_oncall_shifts_from_ical( def get_shifts_dict(calendar, calendar_type, schedule, datetime_start, datetime_end, date, with_empty_shifts=False): - OnCallScheduleWeb = apps.get_model("schedules", "OnCallScheduleWeb") events = ical_events.get_events_from_ical_between(calendar, datetime_start, datetime_end) result_datetime = [] result_date = [] @@ -177,14 +176,7 @@ def get_shifts_dict(calendar, calendar_type, schedule, datetime_start, datetime_ # Define on-call shift out of ical event that has the actual user if len(users) > 0 or with_empty_shifts: if type(event[ICAL_DATETIME_START].dt) == datetime.date: - if isinstance(schedule, OnCallScheduleWeb): - start = max(event[ICAL_DATETIME_START].dt, event[ICAL_DATETIME_STAMP].dt.date()) - end = event[ICAL_DATETIME_END].dt - if event.get(ICAL_RRULE, {}).get(ICAL_UNTIL): - end = min(event[ICAL_DATETIME_END].dt, event[ICAL_RRULE][ICAL_UNTIL][0].date()) - else: - start = event[ICAL_DATETIME_START].dt - end = event[ICAL_DATETIME_END].dt + start, end = get_start_and_end_with_respect_to_event_type(event, full_day_event=True) if start <= date < end: result_date.append( { @@ -199,22 +191,11 @@ def get_shifts_dict(calendar, calendar_type, schedule, datetime_start, datetime_ } ) else: - if isinstance(schedule, OnCallScheduleWeb): - start = max( - event[ICAL_DATETIME_START].dt.astimezone(pytz.UTC), - event[ICAL_DATETIME_STAMP].dt.astimezone(pytz.UTC), - ) - end = event[ICAL_DATETIME_END].dt.astimezone(pytz.UTC) - if event.get(ICAL_RRULE, {}).get(ICAL_UNTIL): - end = min(event[ICAL_DATETIME_END].dt.astimezone(pytz.UTC), event[ICAL_RRULE][ICAL_UNTIL][0]) - else: - start = event[ICAL_DATETIME_START].dt.astimezone(pytz.UTC) - end = event[ICAL_DATETIME_END].dt.astimezone(pytz.UTC) - + start, end = get_start_and_end_with_respect_to_event_type(event) result_datetime.append( { - "start": start, - "end": end, + "start": start.astimezone(pytz.UTC), + "end": end.astimezone(pytz.UTC), "users": users, "missing_users": missing_users, "priority": priority, @@ -762,3 +743,28 @@ def convert_windows_timezone_to_iana(tz_name): logger.debug("Converting the timezone from Windows to IANA. '{}' -> '{}'".format(tz_name, result)) return result + + +def get_start_and_end_with_respect_to_event_type(event, full_day_event=False): + """ + Calculate start and end datetime (or dates for full_day_event) + """ + CustomOnCallShift = apps.get_model("schedules", "CustomOnCallShift") + + start = event[ICAL_DATETIME_START].dt + end = event[ICAL_DATETIME_END].dt + + match = RE_EVENT_UID_V2.match(event[ICAL_UID]) or RE_EVENT_UID_V1.match(event[ICAL_UID]) + # use different calculation rule for events from custom shifts generated at web + if match and int(match.groups()[-1]) == CustomOnCallShift.SOURCE_WEB: + rotation_start = event[ICAL_DATETIME_STAMP] + until = event.get(ICAL_RRULE, {}).get(ICAL_UNTIL) + + if full_day_event: + rotation_start = rotation_start.date() + until = until.date() if until else None + + start = max(start, rotation_start) + end = min(end, until) if until else end + + return start, end From 0fae8b7dcfbd95c26568a165a97825c1992c4b05 Mon Sep 17 00:00:00 2001 From: Julia Date: Thu, 18 Aug 2022 14:28:48 +0300 Subject: [PATCH 6/6] Fix circular import --- .../amixr_recurring_ical_events_adapter.py | 38 +++++++++++++++++-- .../schedules/ical_events/proxy/ical_proxy.py | 10 ++++- engine/apps/schedules/ical_utils.py | 33 ++-------------- 3 files changed, 47 insertions(+), 34 deletions(-) diff --git a/engine/apps/schedules/ical_events/adapter/amixr_recurring_ical_events_adapter.py b/engine/apps/schedules/ical_events/adapter/amixr_recurring_ical_events_adapter.py index b5729313..24f56ee5 100644 --- a/engine/apps/schedules/ical_events/adapter/amixr_recurring_ical_events_adapter.py +++ b/engine/apps/schedules/ical_events/adapter/amixr_recurring_ical_events_adapter.py @@ -1,13 +1,23 @@ from collections import defaultdict from datetime import datetime -from typing import List +from typing import List, Tuple +from django.apps import apps from django.utils import timezone from icalendar import Calendar, Event from recurring_ical_events import UnfoldableCalendar, compare_greater, is_event, time_span_contains_event +from apps.schedules.constants import ( + ICAL_DATETIME_END, + ICAL_DATETIME_STAMP, + ICAL_DATETIME_START, + ICAL_RRULE, + ICAL_UID, + ICAL_UNTIL, + RE_EVENT_UID_V1, + RE_EVENT_UID_V2, +) from apps.schedules.ical_events.proxy.ical_proxy import IcalService -from apps.schedules.ical_utils import get_start_and_end_with_respect_to_event_type EXTRA_LOOKUP_DAYS = 16 @@ -95,7 +105,29 @@ class AmixrRecurringIcalEventsAdapter(IcalService): ) def filter_extra_days(event): - event_start, event_end = get_start_and_end_with_respect_to_event_type(event) + event_start, event_end = self.get_start_and_end_with_respect_to_event_type(event) return time_span_contains_event(start_date, end_date, event_start, event_end) return list(filter(filter_extra_days, events)) + + def get_start_and_end_with_respect_to_event_type(self, event: Event) -> Tuple[timezone.datetime, timezone.datetime]: + """ + Calculate start and end datetime + """ + CustomOnCallShift = apps.get_model("schedules", "CustomOnCallShift") + + start = event[ICAL_DATETIME_START].dt + end = event[ICAL_DATETIME_END].dt + + match = RE_EVENT_UID_V2.match(event[ICAL_UID]) or RE_EVENT_UID_V1.match(event[ICAL_UID]) + # use different calculation rule for events from custom shifts generated at web + if match and int(match.groups()[-1]) == CustomOnCallShift.SOURCE_WEB: + rotation_start = event[ICAL_DATETIME_STAMP].dt + until_rrule = event.get(ICAL_RRULE, {}).get(ICAL_UNTIL) + if until_rrule: + until = until_rrule[0] + end = min(end, until) + + start = max(start, rotation_start) + + return start, end diff --git a/engine/apps/schedules/ical_events/proxy/ical_proxy.py b/engine/apps/schedules/ical_events/proxy/ical_proxy.py index a569c905..7418a7d1 100644 --- a/engine/apps/schedules/ical_events/proxy/ical_proxy.py +++ b/engine/apps/schedules/ical_events/proxy/ical_proxy.py @@ -1,7 +1,8 @@ from abc import ABC, abstractmethod from datetime import datetime -from typing import List +from typing import List, Tuple +from django.utils import timezone from icalendar import Calendar, Event @@ -10,6 +11,10 @@ class IcalService(ABC): def get_events_from_ical_between(self, calendar: Calendar, start_date: datetime, end_date: datetime) -> List[Event]: raise NotImplementedError + @abstractmethod + def get_start_and_end_with_respect_to_event_type(self, event: Event) -> Tuple[timezone.datetime, timezone.datetime]: + raise NotImplementedError + class IcalProxy(IcalService): def __init__(self, ical_adapter: IcalService): @@ -17,3 +22,6 @@ class IcalProxy(IcalService): def get_events_from_ical_between(self, calendar: Calendar, start_date: datetime, end_date: datetime) -> List[Event]: return self.ical_adapter.get_events_from_ical_between(calendar, start_date, end_date) + + def get_start_and_end_with_respect_to_event_type(self, event: Event) -> Tuple[timezone.datetime, timezone.datetime]: + return self.ical_adapter.get_start_and_end_with_respect_to_event_type(event) diff --git a/engine/apps/schedules/ical_utils.py b/engine/apps/schedules/ical_utils.py index 49206b8b..d78b99af 100644 --- a/engine/apps/schedules/ical_utils.py +++ b/engine/apps/schedules/ical_utils.py @@ -16,13 +16,10 @@ from icalendar import Calendar from apps.schedules.constants import ( ICAL_ATTENDEE, ICAL_DATETIME_END, - ICAL_DATETIME_STAMP, ICAL_DATETIME_START, ICAL_DESCRIPTION, - ICAL_RRULE, ICAL_SUMMARY, ICAL_UID, - ICAL_UNTIL, RE_EVENT_UID_V1, RE_EVENT_UID_V2, RE_PRIORITY, @@ -176,7 +173,8 @@ def get_shifts_dict(calendar, calendar_type, schedule, datetime_start, datetime_ # Define on-call shift out of ical event that has the actual user if len(users) > 0 or with_empty_shifts: if type(event[ICAL_DATETIME_START].dt) == datetime.date: - start, end = get_start_and_end_with_respect_to_event_type(event, full_day_event=True) + start = event[ICAL_DATETIME_START].dt + end = event[ICAL_DATETIME_END].dt if start <= date < end: result_date.append( { @@ -191,7 +189,7 @@ def get_shifts_dict(calendar, calendar_type, schedule, datetime_start, datetime_ } ) else: - start, end = get_start_and_end_with_respect_to_event_type(event) + start, end = ical_events.get_start_and_end_with_respect_to_event_type(event) result_datetime.append( { "start": start.astimezone(pytz.UTC), @@ -743,28 +741,3 @@ def convert_windows_timezone_to_iana(tz_name): logger.debug("Converting the timezone from Windows to IANA. '{}' -> '{}'".format(tz_name, result)) return result - - -def get_start_and_end_with_respect_to_event_type(event, full_day_event=False): - """ - Calculate start and end datetime (or dates for full_day_event) - """ - CustomOnCallShift = apps.get_model("schedules", "CustomOnCallShift") - - start = event[ICAL_DATETIME_START].dt - end = event[ICAL_DATETIME_END].dt - - match = RE_EVENT_UID_V2.match(event[ICAL_UID]) or RE_EVENT_UID_V1.match(event[ICAL_UID]) - # use different calculation rule for events from custom shifts generated at web - if match and int(match.groups()[-1]) == CustomOnCallShift.SOURCE_WEB: - rotation_start = event[ICAL_DATETIME_STAMP] - until = event.get(ICAL_RRULE, {}).get(ICAL_UNTIL) - - if full_day_event: - rotation_start = rotation_start.date() - until = until.date() if until else None - - start = max(start, rotation_start) - end = min(end, until) if until else end - - return start, end