diff --git a/engine/apps/api/tests/test_oncall_shift.py b/engine/apps/api/tests/test_oncall_shift.py index ff752555..25e48a53 100644 --- a/engine/apps/api/tests/test_oncall_shift.py +++ b/engine/apps/api/tests/test_oncall_shift.py @@ -1313,6 +1313,90 @@ def test_on_call_shift_preview( assert returned_events == expected_events +@pytest.mark.django_db +def test_on_call_shift_preview_without_users( + make_organization_and_user_with_plugin_token, + make_user_for_organization, + make_user_auth_headers, + make_schedule, +): + organization, user, token = make_organization_and_user_with_plugin_token() + client = APIClient() + + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleWeb, + name="test_web_schedule", + ) + + now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + start_date = now - timezone.timedelta(days=7) + request_date = start_date + user = make_user_for_organization(organization) + + url = "{}?date={}&days={}".format( + reverse("api-internal:oncall_shifts-preview"), request_date.strftime("%Y-%m-%d"), 1 + ) + shift_start = (start_date + timezone.timedelta(hours=12)).strftime("%Y-%m-%dT%H:%M:%SZ") + shift_end = (start_date + timezone.timedelta(hours=13)).strftime("%Y-%m-%dT%H:%M:%SZ") + shift_data = { + "schedule": schedule.public_primary_key, + "type": CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, + "rotation_start": shift_start, + "shift_start": shift_start, + "shift_end": shift_end, + # passing empty users + "rolling_users": [], + "priority_level": 2, + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + } + response = client.post(url, shift_data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + + # check rotation events + rotation_events = response.json()["rotation"] + expected_rotation_events = [ + { + "calendar_type": OnCallSchedule.TYPE_ICAL_PRIMARY, + "start": shift_start, + "end": shift_end, + "all_day": False, + "is_override": False, + "is_empty": True, + "is_gap": False, + "priority_level": None, + "missing_users": [], + "users": [], + "source": "web", + } + ] + # there isn't a saved shift, we don't care/know the temp pk + _ = [r.pop("shift") for r in rotation_events] + assert rotation_events == expected_rotation_events + + # check final schedule events + final_events = response.json()["final"] + expected_events = [ + { + "end": shift_end, + "start": shift_start, + "user": None, + "is_empty": True, + } + ] + returned_events = [ + { + "end": e["end"], + "start": e["start"], + "user": e["users"][0]["display_name"] if e["users"] else None, + "is_empty": e["is_empty"], + } + for e in final_events + if not e["is_override"] and not e["is_gap"] + ] + assert returned_events == expected_events + + @pytest.mark.django_db def test_on_call_shift_preview_merge_events( make_organization_and_user_with_plugin_token, diff --git a/engine/apps/api/views/on_call_shifts.py b/engine/apps/api/views/on_call_shifts.py index a54813b8..548a9df6 100644 --- a/engine/apps/api/views/on_call_shifts.py +++ b/engine/apps/api/views/on_call_shifts.py @@ -89,8 +89,6 @@ class OnCallShiftView(PublicPrimaryKeyMixin, UpdateSerializerMixin, ModelViewSet validated_data = serializer._correct_validated_data( serializer.validated_data["type"], serializer.validated_data ) - if not validated_data.get("rolling_users"): - return Response(data=serializer.errors, status=status.HTTP_400_BAD_REQUEST) updated_shift_pk = self.request.data.get("shift_pk") shift = CustomOnCallShift(**validated_data) diff --git a/engine/apps/schedules/models/custom_on_call_shift.py b/engine/apps/schedules/models/custom_on_call_shift.py index b3fcd8fb..a58cad47 100644 --- a/engine/apps/schedules/models/custom_on_call_shift.py +++ b/engine/apps/schedules/models/custom_on_call_shift.py @@ -273,7 +273,7 @@ class CustomOnCallShift(models.Model): return is_finished - def convert_to_ical(self, time_zone="UTC"): + def convert_to_ical(self, time_zone="UTC", allow_empty_users=False): result = "" # use shift time_zone if it exists, otherwise use schedule or default time_zone time_zone = self.time_zone if self.time_zone is not None else time_zone @@ -285,8 +285,10 @@ class CustomOnCallShift(models.Model): all_rotation_checked = False users_queue = self.get_rolling_users() - if not users_queue: + if not users_queue and not allow_empty_users: return result + if not users_queue and allow_empty_users: + users_queue = [[None]] if self.frequency is None: users_queue = users_queue[:1] @@ -354,7 +356,8 @@ class CustomOnCallShift(models.Model): current_event = Event.from_ical(event_ical) # take shift interval, not event interval. For rolling_users shift it is not the same. interval = self.interval or 1 - current_event["rrule"]["INTERVAL"] = interval + if "rrule" in current_event: + current_event["rrule"]["INTERVAL"] = interval current_event_start = current_event["DTSTART"].dt next_event_start = current_event_start # Calculate the minimum start date for the next event based on rotation frequency. We don't need to do this @@ -482,7 +485,8 @@ class CustomOnCallShift(models.Model): rolling_users = self.rolling_users for users_dict in rolling_users: users_list = list(users.filter(pk__in=users_dict.keys())) - users_queue.append(users_list) + if users_list: + users_queue.append(users_list) return users_queue def add_rolling_users(self, rolling_users_list): diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index abc5d637..11b37d7f 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -571,7 +571,7 @@ class OnCallScheduleCalendar(OnCallSchedule): class OnCallScheduleWeb(OnCallSchedule): time_zone = models.CharField(max_length=100, default="UTC") - def _generate_ical_file_from_shifts(self, qs, extra_shifts=None): + def _generate_ical_file_from_shifts(self, qs, extra_shifts=None, allow_empty_users=False): """Generate iCal events file from custom on-call shifts.""" ical = None if qs.exists() or extra_shifts is not None: @@ -586,7 +586,7 @@ class OnCallScheduleWeb(OnCallSchedule): ical = ical_file.replace(end_line, "").strip() ical = f"{ical}\r\n" for event in itertools.chain(qs.all(), extra_shifts): - ical += event.convert_to_ical(self.time_zone) + ical += event.convert_to_ical(self.time_zone, allow_empty_users=allow_empty_users) ical += f"{end_line}\r\n" return ical @@ -657,7 +657,7 @@ class OnCallScheduleWeb(OnCallSchedule): custom_shift.public_primary_key = updated_shift_pk qs = qs.exclude(public_primary_key=updated_shift_pk) - ical_file = self._generate_ical_file_from_shifts(qs, extra_shifts=extra_shifts) + ical_file = self._generate_ical_file_from_shifts(qs, extra_shifts=extra_shifts, allow_empty_users=True) original_value = getattr(self, ical_attr) _invalidate_cache(self, ical_property) diff --git a/engine/apps/schedules/tests/test_on_call_schedule.py b/engine/apps/schedules/tests/test_on_call_schedule.py index 48e1e244..7e6d52d8 100644 --- a/engine/apps/schedules/tests/test_on_call_schedule.py +++ b/engine/apps/schedules/tests/test_on_call_schedule.py @@ -552,6 +552,78 @@ def test_preview_shift(make_organization, make_user_for_organization, make_sched assert schedule._ical_file_primary == schedule_primary_ical +@pytest.mark.django_db +def test_preview_shift_no_user(make_organization, make_user_for_organization, make_schedule, make_on_call_shift): + organization = make_organization() + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleWeb, + name="test_web_schedule", + ) + now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + start_date = now - timezone.timedelta(days=7) + + schedule_primary_ical = schedule._ical_file_primary + + # proposed shift + new_shift = CustomOnCallShift( + type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, + organization=organization, + schedule=schedule, + name="testing", + start=start_date + timezone.timedelta(hours=12), + rotation_start=start_date + timezone.timedelta(hours=12), + duration=timezone.timedelta(seconds=3600), + frequency=CustomOnCallShift.FREQUENCY_DAILY, + priority_level=2, + rolling_users=[], + ) + + rotation_events, final_events = schedule.preview_shift(new_shift, "UTC", start_date, days=1) + + # check rotation events + expected_rotation_events = [ + { + "calendar_type": OnCallSchedule.TYPE_ICAL_PRIMARY, + "start": new_shift.start, + "end": new_shift.start + new_shift.duration, + "all_day": False, + "is_override": False, + "is_empty": True, + "is_gap": False, + "priority_level": None, + "missing_users": [], + "users": [], + "shift": {"pk": new_shift.public_primary_key}, + "source": "api", + } + ] + assert rotation_events == expected_rotation_events + + expected_events = [ + { + "end": new_shift.start + new_shift.duration, + "start": new_shift.start, + "user": None, + "is_empty": True, + } + ] + returned_events = [ + { + "end": e["end"], + "start": e["start"], + "user": e["users"][0]["display_name"] if e["users"] else None, + "is_empty": e["is_empty"], + } + for e in final_events + if not e["is_override"] and not e["is_gap"] + ] + assert returned_events == expected_events + + # final ical schedule didn't change + assert schedule._ical_file_primary == schedule_primary_ical + + @pytest.mark.django_db def test_preview_override_shift(make_organization, make_user_for_organization, make_schedule, make_on_call_shift): organization = make_organization()