From a153d283e44c07e80479eb663d2968c2c9b99c95 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Mon, 4 Jul 2022 17:51:56 -0300 Subject: [PATCH 1/5] Initial web schedule model and serializers. Add override shift type. --- .../api/serializers/schedule_polymorphic.py | 8 +- engine/apps/api/serializers/schedule_web.py | 46 ++++++ engine/apps/api/tests/test_schedules.py | 125 +++++++++++++++-- .../public_api/serializers/on_call_shifts.py | 31 +++- .../serializers/schedules_polymorphic.py | 7 +- .../public_api/serializers/schedules_web.py | 96 +++++++++++++ .../public_api/tests/test_on_call_shifts.py | 71 +++++++++- .../apps/public_api/tests/test_schedules.py | 132 +++++++++++++++++- .../migrations/0005_auto_20220704_1947.py | 36 +++++ engine/apps/schedules/models/__init__.py | 7 +- .../schedules/models/custom_on_call_shift.py | 16 ++- .../apps/schedules/models/on_call_schedule.py | 66 +++++++++ engine/apps/schedules/tests/factories.py | 7 +- .../tests/test_custom_on_call_shift.py | 66 ++++++++- .../src/models/schedule/schedule.types.ts | 1 + .../src/pages/schedules/Schedules.tsx | 2 +- 16 files changed, 691 insertions(+), 26 deletions(-) create mode 100644 engine/apps/api/serializers/schedule_web.py create mode 100644 engine/apps/public_api/serializers/schedules_web.py create mode 100644 engine/apps/schedules/migrations/0005_auto_20220704_1947.py diff --git a/engine/apps/api/serializers/schedule_polymorphic.py b/engine/apps/api/serializers/schedule_polymorphic.py index b9d404ed..9da80611 100644 --- a/engine/apps/api/serializers/schedule_polymorphic.py +++ b/engine/apps/api/serializers/schedule_polymorphic.py @@ -6,7 +6,8 @@ from apps.api.serializers.schedule_ical import ( ScheduleICalSerializer, ScheduleICalUpdateSerializer, ) -from apps.schedules.models import OnCallScheduleCalendar, OnCallScheduleICal +from apps.api.serializers.schedule_web import ScheduleWebCreateSerializer, ScheduleWebSerializer +from apps.schedules.models import OnCallScheduleCalendar, OnCallScheduleICal, OnCallScheduleWeb from common.api_helpers.mixins import EagerLoadingMixin @@ -18,9 +19,10 @@ class PolymorphicScheduleSerializer(EagerLoadingMixin, PolymorphicSerializer): model_serializer_mapping = { OnCallScheduleICal: ScheduleICalSerializer, OnCallScheduleCalendar: ScheduleCalendarSerializer, + OnCallScheduleWeb: ScheduleWebSerializer, } - SCHEDULE_CLASS_TO_TYPE = {OnCallScheduleCalendar: 0, OnCallScheduleICal: 1} + SCHEDULE_CLASS_TO_TYPE = {OnCallScheduleCalendar: 0, OnCallScheduleICal: 1, OnCallScheduleWeb: 2} def to_resource_type(self, model_or_instance): return self.SCHEDULE_CLASS_TO_TYPE.get(model_or_instance._meta.model) @@ -31,6 +33,7 @@ class PolymorphicScheduleCreateSerializer(PolymorphicScheduleSerializer): model_serializer_mapping = { OnCallScheduleICal: ScheduleICalCreateSerializer, OnCallScheduleCalendar: ScheduleCalendarCreateSerializer, + OnCallScheduleWeb: ScheduleWebCreateSerializer, } @@ -39,4 +42,5 @@ class PolymorphicScheduleUpdateSerializer(PolymorphicScheduleSerializer): OnCallScheduleICal: ScheduleICalUpdateSerializer, # There is no difference between create and Update serializers for ScheduleCalendar OnCallScheduleCalendar: ScheduleCalendarCreateSerializer, + OnCallScheduleWeb: ScheduleWebCreateSerializer, } diff --git a/engine/apps/api/serializers/schedule_web.py b/engine/apps/api/serializers/schedule_web.py new file mode 100644 index 00000000..fadc8b4b --- /dev/null +++ b/engine/apps/api/serializers/schedule_web.py @@ -0,0 +1,46 @@ +from rest_framework import serializers + +from apps.api.serializers.schedule_base import ScheduleBaseSerializer +from apps.schedules.models import OnCallScheduleWeb +from apps.schedules.tasks import schedule_notify_about_empty_shifts_in_schedule, schedule_notify_about_gaps_in_schedule +from apps.slack.models import SlackChannel, SlackUserGroup +from common.api_helpers.custom_fields import OrganizationFilteredPrimaryKeyRelatedField + + +class ScheduleWebSerializer(ScheduleBaseSerializer): + time_zone = serializers.CharField(required=False) + + class Meta: + model = OnCallScheduleWeb + fields = [*ScheduleBaseSerializer.Meta.fields, "slack_channel", "time_zone"] + + +class ScheduleWebCreateSerializer(ScheduleWebSerializer): + slack_channel_id = OrganizationFilteredPrimaryKeyRelatedField( + filter_field="slack_team_identity__organizations", + queryset=SlackChannel.objects, + required=False, + allow_null=True, + ) + user_group = OrganizationFilteredPrimaryKeyRelatedField( + filter_field="slack_team_identity__organizations", + queryset=SlackUserGroup.objects, + required=False, + allow_null=True, + ) + + class Meta(ScheduleWebSerializer.Meta): + fields = [*ScheduleBaseSerializer.Meta.fields, "slack_channel_id", "time_zone"] + + def update(self, instance, validated_data): + updated_schedule = super().update(instance, validated_data) + + old_time_zone = instance.time_zone + updated_time_zone = updated_schedule.time_zone + if old_time_zone != updated_time_zone: + updated_schedule.drop_cached_ical() + updated_schedule.check_empty_shifts_for_next_week() + updated_schedule.check_gaps_for_next_week() + schedule_notify_about_empty_shifts_in_schedule.apply_async((instance.pk,)) + schedule_notify_about_gaps_in_schedule.apply_async((instance.pk,)) + return updated_schedule diff --git a/engine/apps/api/tests/test_schedules.py b/engine/apps/api/tests/test_schedules.py index 29a8c530..71ad67ae 100644 --- a/engine/apps/api/tests/test_schedules.py +++ b/engine/apps/api/tests/test_schedules.py @@ -9,7 +9,13 @@ from rest_framework.response import Response from rest_framework.serializers import ValidationError from rest_framework.test import APIClient -from apps.schedules.models import CustomOnCallShift, OnCallSchedule, OnCallScheduleCalendar, OnCallScheduleICal +from apps.schedules.models import ( + CustomOnCallShift, + OnCallSchedule, + OnCallScheduleCalendar, + OnCallScheduleICal, + OnCallScheduleWeb, +) from common.constants.role import Role ICAL_URL = "https://calendar.google.com/calendar/ical/amixr.io_37gttuakhrtr75ano72p69rt78%40group.calendar.google.com/private-1d00a680ba5be7426c3eb3ef1616e26d/basic.ics" @@ -41,12 +47,18 @@ def schedule_internal_api_setup( ical_url_primary=ICAL_URL, ) - return user, token, calendar_schedule, ical_schedule, slack_channel + web_schedule = make_schedule( + organization, + schedule_class=OnCallScheduleWeb, + name="test_web_schedule", + ) + + return user, token, calendar_schedule, ical_schedule, web_schedule, slack_channel @pytest.mark.django_db def test_get_list_schedules(schedule_internal_api_setup, make_user_auth_headers): - user, token, calendar_schedule, ical_schedule, slack_channel = schedule_internal_api_setup + user, token, calendar_schedule, ical_schedule, web_schedule, slack_channel = schedule_internal_api_setup client = APIClient() url = reverse("api-internal:schedule-list") @@ -85,6 +97,22 @@ def test_get_list_schedules(schedule_internal_api_setup, make_user_auth_headers) "notify_empty_oncall": 0, "notify_oncall_shift_freq": 1, }, + { + "id": web_schedule.public_primary_key, + "type": 2, + "time_zone": "UTC", + "team": None, + "name": "test_web_schedule", + "slack_channel": None, + "user_group": None, + "warnings": [], + "on_call_now": [], + "has_gaps": False, + "mention_oncall_next": False, + "mention_oncall_start": True, + "notify_empty_oncall": 0, + "notify_oncall_shift_freq": 1, + }, ] response = client.get(url, format="json", **make_user_auth_headers(user, token)) assert response.status_code == status.HTTP_200_OK @@ -93,7 +121,7 @@ def test_get_list_schedules(schedule_internal_api_setup, make_user_auth_headers) @pytest.mark.django_db def test_get_detail_calendar_schedule(schedule_internal_api_setup, make_user_auth_headers): - user, token, calendar_schedule, _, _ = schedule_internal_api_setup + user, token, calendar_schedule, _, _, _ = schedule_internal_api_setup client = APIClient() url = reverse("api-internal:schedule-detail", kwargs={"pk": calendar_schedule.public_primary_key}) @@ -122,7 +150,7 @@ def test_get_detail_calendar_schedule(schedule_internal_api_setup, make_user_aut @pytest.mark.django_db def test_get_detail_ical_schedule(schedule_internal_api_setup, make_user_auth_headers): - user, token, _, ical_schedule, _ = schedule_internal_api_setup + user, token, _, ical_schedule, _, _ = schedule_internal_api_setup client = APIClient() url = reverse("api-internal:schedule-detail", kwargs={"pk": ical_schedule.public_primary_key}) @@ -149,9 +177,37 @@ def test_get_detail_ical_schedule(schedule_internal_api_setup, make_user_auth_he assert response.data == expected_payload +@pytest.mark.django_db +def test_get_detail_web_schedule(schedule_internal_api_setup, make_user_auth_headers): + user, token, _, _, web_schedule, _ = schedule_internal_api_setup + client = APIClient() + url = reverse("api-internal:schedule-detail", kwargs={"pk": web_schedule.public_primary_key}) + + expected_payload = { + "id": web_schedule.public_primary_key, + "team": None, + "name": "test_web_schedule", + "type": 2, + "time_zone": "UTC", + "slack_channel": None, + "user_group": None, + "warnings": [], + "on_call_now": [], + "has_gaps": False, + "mention_oncall_next": False, + "mention_oncall_start": True, + "notify_empty_oncall": 0, + "notify_oncall_shift_freq": 1, + } + + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + assert response.data == expected_payload + + @pytest.mark.django_db def test_create_calendar_schedule(schedule_internal_api_setup, make_user_auth_headers): - user, token, _, _, _ = schedule_internal_api_setup + user, token, _, _, _, _ = schedule_internal_api_setup client = APIClient() url = reverse("api-internal:schedule-list") data = { @@ -180,7 +236,7 @@ def test_create_calendar_schedule(schedule_internal_api_setup, make_user_auth_he @pytest.mark.django_db def test_create_ical_schedule(schedule_internal_api_setup, make_user_auth_headers): - user, token, _, _, _ = schedule_internal_api_setup + user, token, _, _, _, _ = schedule_internal_api_setup client = APIClient() url = reverse("api-internal:schedule-list") with patch( @@ -210,9 +266,37 @@ def test_create_ical_schedule(schedule_internal_api_setup, make_user_auth_header assert response.data == data +@pytest.mark.django_db +def test_create_web_schedule(schedule_internal_api_setup, make_user_auth_headers): + user, token, _, _, _, _ = schedule_internal_api_setup + client = APIClient() + url = reverse("api-internal:schedule-list") + data = { + "name": "created_web_schedule", + "type": 2, + "time_zone": "UTC", + "slack_channel_id": None, + "user_group": None, + "team": None, + "warnings": [], + "on_call_now": [], + "has_gaps": False, + "mention_oncall_next": False, + "mention_oncall_start": True, + "notify_empty_oncall": 0, + "notify_oncall_shift_freq": 1, + } + response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) + # modify initial data by adding id and None for optional fields + schedule = OnCallSchedule.objects.get(public_primary_key=response.data["id"]) + data["id"] = schedule.public_primary_key + assert response.status_code == status.HTTP_201_CREATED + assert response.data == data + + @pytest.mark.django_db def test_create_invalid_ical_schedule(schedule_internal_api_setup, make_user_auth_headers): - user, token, _, ical_schedule, _ = schedule_internal_api_setup + user, token, _, ical_schedule, _, _ = schedule_internal_api_setup client = APIClient() url = reverse("api-internal:custom_button-list") with patch( @@ -231,7 +315,7 @@ def test_create_invalid_ical_schedule(schedule_internal_api_setup, make_user_aut @pytest.mark.django_db def test_update_calendar_schedule(schedule_internal_api_setup, make_user_auth_headers): - user, token, calendar_schedule, _, _ = schedule_internal_api_setup + user, token, calendar_schedule, _, _, _ = schedule_internal_api_setup client = APIClient() url = reverse("api-internal:schedule-detail", kwargs={"pk": calendar_schedule.public_primary_key}) @@ -250,7 +334,7 @@ def test_update_calendar_schedule(schedule_internal_api_setup, make_user_auth_he @pytest.mark.django_db def test_update_ical_schedule(schedule_internal_api_setup, make_user_auth_headers): - user, token, _, ical_schedule, _ = schedule_internal_api_setup + user, token, _, ical_schedule, _, _ = schedule_internal_api_setup client = APIClient() url = reverse("api-internal:schedule-detail", kwargs={"pk": ical_schedule.public_primary_key}) @@ -267,9 +351,28 @@ def test_update_ical_schedule(schedule_internal_api_setup, make_user_auth_header assert updated_instance.name == "updated_ical_schedule" +@pytest.mark.django_db +def test_update_web_schedule(schedule_internal_api_setup, make_user_auth_headers): + user, token, _, _, web_schedule, _ = schedule_internal_api_setup + client = APIClient() + + url = reverse("api-internal:schedule-detail", kwargs={"pk": web_schedule.public_primary_key}) + data = { + "name": "updated_web_schedule", + "type": 2, + "team": None, + } + response = client.put( + url, data=json.dumps(data), content_type="application/json", **make_user_auth_headers(user, token) + ) + updated_instance = OnCallSchedule.objects.get(public_primary_key=web_schedule.public_primary_key) + assert response.status_code == status.HTTP_200_OK + assert updated_instance.name == "updated_web_schedule" + + @pytest.mark.django_db def test_delete_schedule(schedule_internal_api_setup, make_user_auth_headers): - user, token, calendar_schedule, ical_schedule, _ = schedule_internal_api_setup + user, token, calendar_schedule, ical_schedule, _, _ = schedule_internal_api_setup client = APIClient() for calendar in (calendar_schedule, ical_schedule): diff --git a/engine/apps/public_api/serializers/on_call_shifts.py b/engine/apps/public_api/serializers/on_call_shifts.py index 97f4f34e..1fd85aa6 100644 --- a/engine/apps/public_api/serializers/on_call_shifts.py +++ b/engine/apps/public_api/serializers/on_call_shifts.py @@ -196,7 +196,7 @@ class CustomOnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer return result def _validate_frequency_and_week_start(self, event_type, frequency, week_start): - if event_type != CustomOnCallShift.TYPE_SINGLE_EVENT: + if event_type not in (CustomOnCallShift.TYPE_SINGLE_EVENT, CustomOnCallShift.TYPE_OVERRIDE): if frequency is None: raise BadRequest(detail="Field 'frequency' is required for this on-call shift type") elif frequency == CustomOnCallShift.FREQUENCY_WEEKLY and week_start is None: @@ -266,6 +266,18 @@ class CustomOnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer ], CustomOnCallShift.TYPE_RECURRENT_EVENT: ["rolling_users", "start_rotation_from_user_index"], CustomOnCallShift.TYPE_ROLLING_USERS_EVENT: ["users"], + CustomOnCallShift.TYPE_OVERRIDE: [ + "level", + "frequency", + "interval", + "until", + "by_day", + "by_month", + "by_monthday", + "week_start", + "rolling_users", + "start_rotation_from_user_index", + ], } for field in fields_to_remove_map[event_type]: result.pop(field, None) @@ -289,9 +301,24 @@ class CustomOnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer ], CustomOnCallShift.TYPE_RECURRENT_EVENT: ["rolling_users", "start_rotation_from_user_index"], CustomOnCallShift.TYPE_ROLLING_USERS_EVENT: ["users"], + CustomOnCallShift.TYPE_OVERRIDE: [ + "priority_level", + "frequency", + "interval", + "by_day", + "by_month", + "by_monthday", + "rolling_users", + "start_rotation_from_user_index", + ], } for field in fields_to_update_map[event_type]: - validated_data[field] = None if field != "users" else [] + value = None + if field == "users": + value = [] + elif field == "priority_level": + value = 0 + validated_data[field] = value validated_data_list_fields = ["by_day", "by_month", "by_monthday", "rolling_users"] diff --git a/engine/apps/public_api/serializers/schedules_polymorphic.py b/engine/apps/public_api/serializers/schedules_polymorphic.py index 54ed9104..4445fdc3 100644 --- a/engine/apps/public_api/serializers/schedules_polymorphic.py +++ b/engine/apps/public_api/serializers/schedules_polymorphic.py @@ -3,7 +3,8 @@ from rest_polymorphic.serializers import PolymorphicSerializer from apps.public_api.serializers.schedules_calendar import ScheduleCalendarSerializer, ScheduleCalendarUpdateSerializer from apps.public_api.serializers.schedules_ical import ScheduleICalSerializer, ScheduleICalUpdateSerializer -from apps.schedules.models import OnCallScheduleCalendar, OnCallScheduleICal +from apps.public_api.serializers.schedules_web import ScheduleWebSerializer, ScheduleWebUpdateSerializer +from apps.schedules.models import OnCallScheduleCalendar, OnCallScheduleICal, OnCallScheduleWeb from common.api_helpers.mixins import EagerLoadingMixin @@ -15,9 +16,10 @@ class PolymorphicScheduleSerializer(EagerLoadingMixin, PolymorphicSerializer): model_serializer_mapping = { OnCallScheduleICal: ScheduleICalSerializer, OnCallScheduleCalendar: ScheduleCalendarSerializer, + OnCallScheduleWeb: ScheduleWebSerializer, } - SCHEDULE_CLASS_TO_TYPE = {OnCallScheduleCalendar: "calendar", OnCallScheduleICal: "ical"} + SCHEDULE_CLASS_TO_TYPE = {OnCallScheduleCalendar: "calendar", OnCallScheduleICal: "ical", OnCallScheduleWeb: "web"} def to_resource_type(self, model_or_instance): return self.SCHEDULE_CLASS_TO_TYPE.get(model_or_instance._meta.model) @@ -27,6 +29,7 @@ class PolymorphicScheduleUpdateSerializer(PolymorphicScheduleSerializer): model_serializer_mapping = { OnCallScheduleICal: ScheduleICalUpdateSerializer, OnCallScheduleCalendar: ScheduleCalendarUpdateSerializer, + OnCallScheduleWeb: ScheduleWebUpdateSerializer, } def update(self, instance, validated_data): diff --git a/engine/apps/public_api/serializers/schedules_web.py b/engine/apps/public_api/serializers/schedules_web.py new file mode 100644 index 00000000..2ed1eed8 --- /dev/null +++ b/engine/apps/public_api/serializers/schedules_web.py @@ -0,0 +1,96 @@ +import pytz +from django.utils import timezone +from rest_framework import serializers + +from apps.public_api.serializers.schedules_base import ScheduleBaseSerializer +from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb +from apps.schedules.tasks import ( + drop_cached_ical_task, + schedule_notify_about_empty_shifts_in_schedule, + schedule_notify_about_gaps_in_schedule, +) +from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField, UsersFilteredByOrganizationField +from common.api_helpers.exceptions import BadRequest + + +class ScheduleWebSerializer(ScheduleBaseSerializer): + time_zone = serializers.CharField(required=True) + shifts = UsersFilteredByOrganizationField( + queryset=CustomOnCallShift.objects, + required=False, + source="custom_shifts", + ) + + class Meta: + model = OnCallScheduleWeb + fields = [ + "id", + "team_id", + "name", + "time_zone", + "slack", + "on_call_now", + "shifts", + ] + + def validate_time_zone(self, tz): + try: + timezone.now().astimezone(pytz.timezone(tz)) + except pytz.exceptions.UnknownTimeZoneError: + raise BadRequest(detail="Invalid time zone") + return tz + + def validate_shifts(self, shifts): + # Get team_id from instance, if it exists, otherwise get it from initial data. + # Handle empty string instead of None. In this case change team_id value to None. + team_id = self.instance.team_id if self.instance else (self.initial_data.get("team_id") or None) + for shift in shifts: + if shift.team_id != team_id: + raise BadRequest(detail="Shifts must be assigned to the same team as the schedule") + + return shifts + + def to_internal_value(self, data): + if data.get("shifts", []) is None: # handle a None value + data["shifts"] = [] + result = super().to_internal_value(data) + return result + + +class ScheduleWebUpdateSerializer(ScheduleWebSerializer): + time_zone = serializers.CharField(required=False) + team_id = TeamPrimaryKeyRelatedField(read_only=True, source="team") + + class Meta: + model = OnCallScheduleWeb + fields = [ + "id", + "team_id", + "name", + "time_zone", + "slack", + "on_call_now", + "shifts", + ] + extra_kwargs = { + "name": {"required": False}, + } + + def update(self, instance, validated_data): + validated_data = self._correct_validated_data(validated_data) + new_time_zone = validated_data.get("time_zone", instance.time_zone) + new_shifts = validated_data.get("shifts", []) + existing_shifts = instance.custom_shifts.all() + + ical_changed = False + + if new_time_zone != instance.time_zone or set(existing_shifts) != set(new_shifts): + ical_changed = True + + if ical_changed: + drop_cached_ical_task.apply_async( + (instance.pk,), + ) + schedule_notify_about_empty_shifts_in_schedule.apply_async((instance.pk,)) + schedule_notify_about_gaps_in_schedule.apply_async((instance.pk,)) + return super().update(instance, validated_data) diff --git a/engine/apps/public_api/tests/test_on_call_shifts.py b/engine/apps/public_api/tests/test_on_call_shifts.py index 3910cb94..8311c00e 100644 --- a/engine/apps/public_api/tests/test_on_call_shifts.py +++ b/engine/apps/public_api/tests/test_on_call_shifts.py @@ -5,7 +5,7 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient -from apps.schedules.models import CustomOnCallShift, OnCallScheduleCalendar +from apps.schedules.models import CustomOnCallShift, OnCallScheduleCalendar, OnCallScheduleWeb invalid_field_data_1 = { "frequency": None, @@ -76,6 +76,39 @@ def test_get_on_call_shift(make_organization_and_user_with_token, make_on_call_s assert response.data == result +@pytest.mark.django_db +def test_get_override_on_call_shift(make_organization_and_user_with_token, make_on_call_shift, make_schedule): + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + data = { + "start": datetime.datetime.now().replace(microsecond=0), + "duration": datetime.timedelta(seconds=7200), + "schedule": schedule, + } + on_call_shift = make_on_call_shift(organization=organization, shift_type=CustomOnCallShift.TYPE_OVERRIDE, **data) + on_call_shift.users.add(user) + + url = reverse("api-public:on_call_shifts-detail", kwargs={"pk": on_call_shift.public_primary_key}) + + response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}") + + result = { + "id": on_call_shift.public_primary_key, + "team_id": None, + "name": on_call_shift.name, + "type": "override", + "time_zone": None, + "start": on_call_shift.start.strftime("%Y-%m-%dT%H:%M:%S"), + "duration": int(on_call_shift.duration.total_seconds()), + "users": [user.public_primary_key], + } + + assert response.status_code == status.HTTP_200_OK + assert response.data == result + + @pytest.mark.django_db def test_create_on_call_shift(make_organization_and_user_with_token): @@ -127,6 +160,42 @@ def test_create_on_call_shift(make_organization_and_user_with_token): assert response.data == result +@pytest.mark.django_db +def test_create_override_on_call_shift(make_organization_and_user_with_token): + + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + + url = reverse("api-public:on_call_shifts-list") + + start = datetime.datetime.now() + data = { + "team_id": None, + "name": "test name", + "type": "override", + "start": start.strftime("%Y-%m-%dT%H:%M:%S"), + "duration": 10800, + "users": [user.public_primary_key], + } + + response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + on_call_shift = CustomOnCallShift.objects.get(public_primary_key=response.data["id"]) + + result = { + "id": on_call_shift.public_primary_key, + "team_id": None, + "name": data["name"], + "type": "override", + "time_zone": None, + "start": data["start"], + "duration": data["duration"], + "users": [user.public_primary_key], + } + + assert response.status_code == status.HTTP_201_CREATED + assert response.data == result + + @pytest.mark.django_db def test_update_on_call_shift(make_organization_and_user_with_token, make_on_call_shift, make_schedule): organization, user, token = make_organization_and_user_with_token() diff --git a/engine/apps/public_api/tests/test_schedules.py b/engine/apps/public_api/tests/test_schedules.py index 13777a10..99411b9f 100644 --- a/engine/apps/public_api/tests/test_schedules.py +++ b/engine/apps/public_api/tests/test_schedules.py @@ -6,7 +6,13 @@ from django.utils import timezone from rest_framework import status from rest_framework.test import APIClient -from apps.schedules.models import CustomOnCallShift, OnCallSchedule, OnCallScheduleCalendar, OnCallScheduleICal +from apps.schedules.models import ( + CustomOnCallShift, + OnCallSchedule, + OnCallScheduleCalendar, + OnCallScheduleICal, + OnCallScheduleWeb, +) ICAL_URL = "https://calendar.google.com/calendar/ical/amixr.io_37gttuakhrtr75ano72p69rt78%40group.calendar.google.com/private-1d00a680ba5be7426c3eb3ef1616e26d/basic.ics" @@ -138,6 +144,130 @@ def test_update_calendar_schedule( assert response.json() == result +@pytest.mark.django_db +def test_get_web_schedule( + make_organization_and_user_with_token, + make_schedule, +): + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + + slack_channel_id = "SLACKCHANNELID" + + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleWeb, + channel=slack_channel_id, + ) + + url = reverse("api-public:schedules-detail", kwargs={"pk": schedule.public_primary_key}) + + response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}") + + result = { + "id": schedule.public_primary_key, + "team_id": None, + "name": schedule.name, + "type": "web", + "time_zone": "UTC", + "on_call_now": [], + "shifts": [], + "slack": { + "channel_id": "SLACKCHANNELID", + "user_group_id": None, + }, + } + + assert response.status_code == status.HTTP_200_OK + assert response.json() == result + + +@pytest.mark.django_db +def test_create_web_schedule(make_organization_and_user_with_token): + + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + + url = reverse("api-public:schedules-list") + + data = { + "team_id": None, + "name": "schedule test name", + "time_zone": "Europe/Moscow", + "type": "web", + } + + response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + schedule = OnCallSchedule.objects.get(public_primary_key=response.data["id"]) + + result = { + "id": schedule.public_primary_key, + "team_id": None, + "name": schedule.name, + "type": "web", + "time_zone": "Europe/Moscow", + "on_call_now": [], + "shifts": [], + "slack": { + "channel_id": None, + "user_group_id": None, + }, + } + + assert response.status_code == status.HTTP_201_CREATED + assert response.json() == result + + +@pytest.mark.django_db +def test_update_web_schedule( + make_organization_and_user_with_token, + make_schedule, +): + + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + + slack_channel_id = "SLACKCHANNELID" + + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleWeb, + channel=slack_channel_id, + ) + + url = reverse("api-public:schedules-detail", kwargs={"pk": schedule.public_primary_key}) + + data = { + "name": "RENAMED", + "time_zone": "Europe/Moscow", + } + + assert schedule.name != data["name"] + assert schedule.time_zone != data["time_zone"] + + response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + + result = { + "id": schedule.public_primary_key, + "team_id": None, + "name": data["name"], + "type": "web", + "time_zone": data["time_zone"], + "on_call_now": [], + "shifts": [], + "slack": { + "channel_id": "SLACKCHANNELID", + "user_group_id": None, + }, + } + + assert response.status_code == status.HTTP_200_OK + schedule.refresh_from_db() + assert schedule.name == data["name"] + assert schedule.time_zone == data["time_zone"] + assert response.json() == result + + @pytest.mark.django_db def test_update_ical_url_overrides_calendar_schedule( make_organization_and_user_with_token, diff --git a/engine/apps/schedules/migrations/0005_auto_20220704_1947.py b/engine/apps/schedules/migrations/0005_auto_20220704_1947.py new file mode 100644 index 00000000..03b77e60 --- /dev/null +++ b/engine/apps/schedules/migrations/0005_auto_20220704_1947.py @@ -0,0 +1,36 @@ +# Generated by Django 3.2.5 on 2022-07-04 19:47 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('schedules', '0004_customoncallshift_until'), + ] + + operations = [ + migrations.CreateModel( + name='OnCallScheduleWeb', + fields=[ + ('oncallschedule_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='schedules.oncallschedule')), + ('time_zone', models.CharField(default='UTC', max_length=100)), + ], + options={ + 'abstract': False, + 'base_manager_name': 'objects', + }, + bases=('schedules.oncallschedule',), + ), + migrations.AddField( + model_name='customoncallshift', + name='schedule', + field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='custom_shifts', to='schedules.oncallschedule'), + ), + migrations.AlterField( + model_name='customoncallshift', + name='type', + field=models.IntegerField(choices=[(0, 'Single event'), (1, 'Recurrent event'), (2, 'Rolling users'), (3, 'Override')]), + ), + ] diff --git a/engine/apps/schedules/models/__init__.py b/engine/apps/schedules/models/__init__.py index c5a62790..9300830a 100644 --- a/engine/apps/schedules/models/__init__.py +++ b/engine/apps/schedules/models/__init__.py @@ -1,2 +1,7 @@ from .custom_on_call_shift import CustomOnCallShift # noqa: F401 -from .on_call_schedule import OnCallSchedule, OnCallScheduleCalendar, OnCallScheduleICal # noqa: F401 +from .on_call_schedule import ( # noqa: F401 + OnCallSchedule, + OnCallScheduleCalendar, + OnCallScheduleICal, + OnCallScheduleWeb, +) diff --git a/engine/apps/schedules/models/custom_on_call_shift.py b/engine/apps/schedules/models/custom_on_call_shift.py index 7cb72ef5..aa721d28 100644 --- a/engine/apps/schedules/models/custom_on_call_shift.py +++ b/engine/apps/schedules/models/custom_on_call_shift.py @@ -61,18 +61,21 @@ class CustomOnCallShift(models.Model): TYPE_SINGLE_EVENT, TYPE_RECURRENT_EVENT, TYPE_ROLLING_USERS_EVENT, - ) = range(3) + TYPE_OVERRIDE, + ) = range(4) TYPE_CHOICES = ( (TYPE_SINGLE_EVENT, "Single event"), (TYPE_RECURRENT_EVENT, "Recurrent event"), (TYPE_ROLLING_USERS_EVENT, "Rolling users"), + (TYPE_OVERRIDE, "Override"), ) PUBLIC_TYPE_CHOICES_MAP = { TYPE_SINGLE_EVENT: "single_event", TYPE_RECURRENT_EVENT: "recurrent_event", TYPE_ROLLING_USERS_EVENT: "rolling_users", + TYPE_OVERRIDE: "override", } (MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY) = range(7) @@ -128,6 +131,13 @@ class CustomOnCallShift(models.Model): null=True, default=None, ) + schedule = models.ForeignKey( + "schedules.OnCallSchedule", + on_delete=models.CASCADE, + related_name="custom_shifts", + null=True, + default=None, + ) name = models.CharField(max_length=200) time_zone = models.CharField(max_length=100, null=True, default=None) source = models.IntegerField(choices=SOURCE_CHOICES, default=SOURCE_API) @@ -136,7 +146,7 @@ class CustomOnCallShift(models.Model): start_rotation_from_user_index = models.PositiveIntegerField(null=True, default=None) uuid = models.UUIDField(default=uuid4) # event uuid - type = models.IntegerField(choices=TYPE_CHOICES) # "rolling_users", "recurrent_event", "single_event" + type = models.IntegerField(choices=TYPE_CHOICES) # "rolling_users", "recurrent_event", "single_event", "override" start = models.DateTimeField() # event start datetime duration = models.DurationField() # duration in seconds @@ -191,7 +201,7 @@ class CustomOnCallShift(models.Model): f"source: {self.get_source_display()}, type: {self.get_type_display()}, users: {users_verbal}, " f"start: {self.start.isoformat()}, duration: {self.duration}, priority level: {self.priority_level}" ) - if self.type != CustomOnCallShift.TYPE_SINGLE_EVENT: + if self.type not in (CustomOnCallShift.TYPE_SINGLE_EVENT, CustomOnCallShift.TYPE_OVERRIDE): result += ( f", frequency: {self.get_frequency_display()}, interval: {self.interval}, " f"week start: {self.week_start}, by day: {self.by_day}, by month: {self.by_month}, " diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index 51e7b8b8..9e84d8d3 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -16,6 +16,7 @@ from apps.schedules.ical_utils import ( list_of_gaps_in_schedule, list_users_to_notify_from_ical, ) +from apps.schedules.models import CustomOnCallShift from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length @@ -394,3 +395,68 @@ class OnCallScheduleCalendar(OnCallSchedule): result = super().repr_settings_for_client_side_logging result += f", overrides calendar url: {self.ical_url_overrides}" return result + + +class OnCallScheduleWeb(OnCallSchedule): + time_zone = models.CharField(max_length=100, default="UTC") + + def _generate_ical_file_from_shifts(self, qs): + """Generate iCal events file from custom on-call shifts.""" + ical = None + if qs.exists(): + end_line = "END:VCALENDAR" + calendar = Calendar() + calendar.add("prodid", "-//web schedule//oncall//") + calendar.add("version", "2.0") + calendar.add("method", "PUBLISH") + ical_file = calendar.to_ical().decode() + ical = ical_file.replace(end_line, "").strip() + ical = f"{ical}\r\n" + for event in qs.all(): + ical += event.convert_to_ical(self.time_zone) + ical += f"{end_line}\r\n" + return ical + + def _generate_ical_file_primary(self): + qs = self.custom_shifts.exclude(type=CustomOnCallShift.TYPE_OVERRIDE) + return self._generate_ical_file_from_shifts(qs) + + def _generate_ical_file_overrides(self): + qs = self.custom_shifts.filter(type=CustomOnCallShift.TYPE_OVERRIDE) + return self._generate_ical_file_from_shifts(qs) + + @cached_property + def _ical_file_primary(self): + """Return cached ical file with iCal events from custom on-call shifts.""" + if self.cached_ical_file_primary is None: + self.cached_ical_file_primary = self._generate_ical_file_primary() + self.save(update_fields=["cached_ical_file_primary"]) + return self.cached_ical_file_primary + + def _refresh_primary_ical_file(self): + self.prev_ical_file_primary = self.cached_ical_file_primary + self.cached_ical_file_primary = self._generate_ical_file_primary() + self.save(update_fields=["cached_ical_file_primary", "prev_ical_file_primary"]) + + def _drop_primary_ical_file(self): + self.prev_ical_file_primary = self.cached_ical_file_primary + self.cached_ical_file_primary = None + self.save(update_fields=["cached_ical_file_primary", "prev_ical_file_primary"]) + + @cached_property + def _ical_file_overrides(self): + """Return cached ical file with iCal events from custom on-call overrides shifts.""" + if self.cached_ical_file_overrides is None: + self.cached_ical_file_overrides = self._generate_ical_file_overrides() + self.save(update_fields=["cached_ical_file_overrides"]) + return self.cached_ical_file_overrides + + def _refresh_overrides_ical_file(self): + self.prev_ical_file_overrides = self.cached_ical_file_overrides + self.cached_ical_file_overrides = self._generate_ical_file_overrides() + self.save(update_fields=["cached_ical_file_overrides", "prev_ical_file_overrides"]) + + def _drop_overrides_ical_file(self): + self.prev_ical_file_overrides = self.cached_ical_file_overrides + self.cached_ical_file_overrides = None + self.save(update_fields=["cached_ical_file_overrides", "prev_ical_file_overrides"]) diff --git a/engine/apps/schedules/tests/factories.py b/engine/apps/schedules/tests/factories.py index 50a7e393..63c793e7 100644 --- a/engine/apps/schedules/tests/factories.py +++ b/engine/apps/schedules/tests/factories.py @@ -1,6 +1,6 @@ import factory -from apps.schedules.models import CustomOnCallShift, OnCallScheduleCalendar, OnCallScheduleICal +from apps.schedules.models import CustomOnCallShift, OnCallScheduleCalendar, OnCallScheduleICal, OnCallScheduleWeb from common.utils import UniqueFaker @@ -25,6 +25,11 @@ class OnCallScheduleCalendarFactory(OnCallScheduleFactory): model = OnCallScheduleCalendar +class OnCallScheduleWebFactory(OnCallScheduleFactory): + class Meta: + model = OnCallScheduleWeb + + class CustomOnCallShiftFactory(factory.DjangoModelFactory): name = UniqueFaker("sentence", nb_words=2) 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 c2fb8a26..8e507c89 100644 --- a/engine/apps/schedules/tests/test_custom_on_call_shift.py +++ b/engine/apps/schedules/tests/test_custom_on_call_shift.py @@ -2,7 +2,7 @@ import pytest from django.utils import timezone from apps.schedules.ical_utils import list_users_to_notify_from_ical -from apps.schedules.models import CustomOnCallShift, OnCallSchedule, OnCallScheduleCalendar +from apps.schedules.models import CustomOnCallShift, OnCallSchedule, OnCallScheduleCalendar, OnCallScheduleWeb @pytest.mark.django_db @@ -32,6 +32,29 @@ def test_get_on_call_users_from_single_event(make_organization_and_user, make_on assert user in users_on_call +@pytest.mark.django_db +def test_get_on_call_users_from_web_schedule_override(make_organization_and_user, make_on_call_shift, make_schedule): + organization, user = make_organization_and_user() + + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + date = timezone.now().replace(tzinfo=None, microsecond=0) + + data = { + "start": date, + "duration": timezone.timedelta(seconds=10800), + "schedule": schedule, + } + + on_call_shift = make_on_call_shift(organization=organization, shift_type=CustomOnCallShift.TYPE_OVERRIDE, **data) + on_call_shift.users.add(user) + + # user is on-call + date = date + timezone.timedelta(minutes=5) + users_on_call = list_users_to_notify_from_ical(schedule, date) + assert len(users_on_call) == 1 + assert user in users_on_call + + @pytest.mark.django_db def test_get_on_call_users_from_recurrent_event(make_organization_and_user, make_on_call_shift, make_schedule): organization, user = make_organization_and_user() @@ -72,6 +95,47 @@ def test_get_on_call_users_from_recurrent_event(make_organization_and_user, make assert user in users_on_call +@pytest.mark.django_db +def test_get_on_call_users_from_web_schedule_recurrent_event( + make_organization_and_user, make_on_call_shift, make_schedule +): + organization, user = make_organization_and_user() + + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + date = timezone.now().replace(tzinfo=None, microsecond=0) + + data = { + "priority_level": 1, + "start": date, + "duration": timezone.timedelta(seconds=10800), + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + "interval": 2, + "schedule": schedule, + } + + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_RECURRENT_EVENT, **data + ) + on_call_shift.users.add(user) + + # user is on-call + date = date + timezone.timedelta(minutes=5) + users_on_call = list_users_to_notify_from_ical(schedule, date) + assert len(users_on_call) == 1 + assert user in users_on_call + + # user is not on-call according to event recurrence rules (interval = 2) + date = date + timezone.timedelta(days=1) + users_on_call = list_users_to_notify_from_ical(schedule, date) + assert len(users_on_call) == 0 + + # user is on-call again + date = date + timezone.timedelta(days=1) + users_on_call = list_users_to_notify_from_ical(schedule, date) + assert len(users_on_call) == 1 + assert user in users_on_call + + @pytest.mark.django_db def test_get_on_call_users_from_rolling_users_event( make_organization_and_user, make_user_for_organization, make_on_call_shift, make_schedule diff --git a/grafana-plugin/src/models/schedule/schedule.types.ts b/grafana-plugin/src/models/schedule/schedule.types.ts index d22c4bd8..567be12b 100644 --- a/grafana-plugin/src/models/schedule/schedule.types.ts +++ b/grafana-plugin/src/models/schedule/schedule.types.ts @@ -6,6 +6,7 @@ import { UserGroup } from 'models/user_group/user_group.types'; export enum ScheduleType { 'Calendar', 'Ical', + 'Web', } export interface Schedule { diff --git a/grafana-plugin/src/pages/schedules/Schedules.tsx b/grafana-plugin/src/pages/schedules/Schedules.tsx index d07b39ea..52501020 100644 --- a/grafana-plugin/src/pages/schedules/Schedules.tsx +++ b/grafana-plugin/src/pages/schedules/Schedules.tsx @@ -350,7 +350,7 @@ class SchedulesPage extends React.Component Date: Tue, 5 Jul 2022 15:50:38 -0300 Subject: [PATCH 2/5] Add schedule filter_events plugin API --- engine/apps/api/tests/test_schedules.py | 138 ++++++++++++++++++++++++ engine/apps/api/views/schedule.py | 59 ++++++++-- engine/apps/schedules/ical_utils.py | 36 ++++--- 3 files changed, 209 insertions(+), 24 deletions(-) diff --git a/engine/apps/api/tests/test_schedules.py b/engine/apps/api/tests/test_schedules.py index 71ad67ae..b0e61d54 100644 --- a/engine/apps/api/tests/test_schedules.py +++ b/engine/apps/api/tests/test_schedules.py @@ -429,6 +429,144 @@ def test_events_calendar( "calendar_type": OnCallSchedule.PRIMARY, "is_empty": False, "is_gap": False, + "shift_uuid": str(on_call_shift.uuid), + } + ], + } + assert response.status_code == status.HTTP_200_OK + assert response.data == expected_result + + +@pytest.mark.django_db +def test_filter_events_calendar( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + make_schedule, + make_on_call_shift, +): + 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(microsecond=0) + start_date = now - timezone.timedelta(days=7) + data = { + "start": start_date, + "duration": timezone.timedelta(seconds=7200), + "priority_level": 1, + "frequency": CustomOnCallShift.FREQUENCY_WEEKLY, + "by_day": ["MO", "FR"], + "schedule": schedule, + } + + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_RECURRENT_EVENT, **data + ) + on_call_shift.users.add(user) + + url = reverse("api-internal:schedule-filter-events", kwargs={"pk": schedule.public_primary_key}) + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + + # current week events are expected + mon_start = now - timezone.timedelta(days=start_date.weekday()) + fri_start = mon_start + timezone.timedelta(days=4) + expected_result = { + "id": schedule.public_primary_key, + "name": "test_web_schedule", + "type": 2, + "events": [ + { + "all_day": False, + "start": mon_start, + "end": mon_start + on_call_shift.duration, + "users": [{"display_name": user.username, "pk": user.public_primary_key}], + "priority_level": on_call_shift.priority_level, + "source": "api", + "calendar_type": OnCallSchedule.PRIMARY, + "is_empty": False, + "is_gap": False, + "shift_uuid": str(on_call_shift.uuid), + }, + { + "all_day": False, + "start": fri_start, + "end": fri_start + on_call_shift.duration, + "users": [{"display_name": user.username, "pk": user.public_primary_key}], + "priority_level": on_call_shift.priority_level, + "source": "api", + "calendar_type": OnCallSchedule.PRIMARY, + "is_empty": False, + "is_gap": False, + "shift_uuid": str(on_call_shift.uuid), + }, + ], + } + assert response.status_code == status.HTTP_200_OK + assert response.data == expected_result + + +@pytest.mark.django_db +def test_filter_events_range_calendar( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + make_schedule, + make_on_call_shift, +): + 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(microsecond=0) + start_date = now - timezone.timedelta(days=7) + data = { + "start": start_date, + "duration": timezone.timedelta(seconds=7200), + "priority_level": 1, + "frequency": CustomOnCallShift.FREQUENCY_WEEKLY, + "by_day": ["MO", "FR"], + "schedule": schedule, + } + + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_RECURRENT_EVENT, **data + ) + on_call_shift.users.add(user) + + mon_start = now - timezone.timedelta(days=start_date.weekday()) + request_date = mon_start + timezone.timedelta(days=2) + + url = reverse("api-internal:schedule-filter-events", kwargs={"pk": schedule.public_primary_key}) + url += "?date={}&days=3".format(request_date.strftime("%Y-%m-%d")) + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + + # only friday occurrence is expected + fri_start = mon_start + timezone.timedelta(days=4) + expected_result = { + "id": schedule.public_primary_key, + "name": "test_web_schedule", + "type": 2, + "events": [ + { + "all_day": False, + "start": fri_start, + "end": fri_start + on_call_shift.duration, + "users": [{"display_name": user.username, "pk": user.public_primary_key}], + "priority_level": on_call_shift.priority_level, + "source": "api", + "calendar_type": OnCallSchedule.PRIMARY, + "is_empty": False, + "is_gap": False, + "shift_uuid": str(on_call_shift.uuid), } ], } diff --git a/engine/apps/api/views/schedule.py b/engine/apps/api/views/schedule.py index d27676d4..819950eb 100644 --- a/engine/apps/api/views/schedule.py +++ b/engine/apps/api/views/schedule.py @@ -54,6 +54,7 @@ class ScheduleView( AnyRole: ( *READ_ACTIONS, "events", + "filter_events", "notify_empty_oncall_options", "notify_oncall_shift_freq_options", "mention_options", @@ -190,14 +191,11 @@ class ScheduleView( return user_tz, date - @action(detail=True, methods=["get"]) - def events(self, request, pk): - user_tz, date = self.get_request_timezone() - with_empty = self.request.query_params.get("with_empty", False) == "true" - with_gap = self.request.query_params.get("with_gap", False) == "true" - schedule = self.original_get_object() - shifts = list_of_oncall_shifts_from_ical(schedule, date, user_tz, with_empty, with_gap) or [] - events_result = [] + def _filter_events(self, schedule, timezone, starting_date, days, with_empty, with_gap): + shifts = ( + list_of_oncall_shifts_from_ical(schedule, starting_date, timezone, with_empty, with_gap, days=days) or [] + ) + events = [] # for start, end, users, priority_level, source in shifts: for shift in shifts: all_day = type(shift["start"]) == datetime.date @@ -219,8 +217,20 @@ class ScheduleView( "calendar_type": shift["calendar_type"], "is_empty": len(shift["users"]) == 0 and not is_gap, "is_gap": is_gap, + "shift_uuid": shift["shift_uuid"], } - events_result.append(shift_json) + events.append(shift_json) + + return events + + @action(detail=True, methods=["get"]) + def events(self, request, pk): + user_tz, date = self.get_request_timezone() + with_empty = self.request.query_params.get("with_empty", False) == "true" + with_gap = self.request.query_params.get("with_gap", False) == "true" + + schedule = self.original_get_object() + events = self._filter_events(schedule, user_tz, date, days=1, with_empty=with_empty, with_gap=with_gap) slack_channel = ( { @@ -237,7 +247,36 @@ class ScheduleView( "name": schedule.name, "type": PolymorphicScheduleSerializer().to_resource_type(schedule), "slack_channel": slack_channel, - "events": events_result, + "events": events, + } + return Response(result, status=status.HTTP_200_OK) + + @action(detail=True, methods=["get"]) + def filter_events(self, request, pk): + user_tz, date = self.get_request_timezone() + with_empty = self.request.query_params.get("with_empty", False) == "true" + with_gap = self.request.query_params.get("with_gap", False) == "true" + + starting_date = date if self.request.query_params.get("date") else None + if starting_date is None: + # default to current week start + starting_date = date - datetime.timedelta(days=date.weekday()) + + try: + days = int(self.request.query_params.get("days", 7)) # fallback to a week + except ValueError: + raise BadRequest(detail="Invalid days format") + + schedule = self.original_get_object() + events = self._filter_events( + schedule, user_tz, starting_date, days=days, with_empty=with_empty, with_gap=with_gap + ) + + result = { + "id": schedule.public_primary_key, + "name": schedule.name, + "type": PolymorphicScheduleSerializer().to_resource_type(schedule), + "events": events, } return Response(result, status=status.HTTP_200_OK) diff --git a/engine/apps/schedules/ical_utils.py b/engine/apps/schedules/ical_utils.py index cb0bc342..8bd1c967 100644 --- a/engine/apps/schedules/ical_utils.py +++ b/engine/apps/schedules/ical_utils.py @@ -75,12 +75,15 @@ ICAL_DESCRIPTION = "DESCRIPTION" ICAL_ATTENDEE = "ATTENDEE" ICAL_UID = "UID" RE_PRIORITY = re.compile(r"^\[L(\d)\]") +RE_EVENT_UID = re.compile(r"amixr-([\w\d-]+)-U(\d+)-E(\d+)-S(\d+)") logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) # used for display schedule events on web -def list_of_oncall_shifts_from_ical(schedule, date, user_timezone="UTC", with_empty_shifts=False, with_gaps=False): +def list_of_oncall_shifts_from_ical( + schedule, date, user_timezone="UTC", with_empty_shifts=False, with_gaps=False, days=1 +): """ Parse the ical file and return list of events with users This function is used in serializer for api schedules/events/ endpoint @@ -106,7 +109,7 @@ def list_of_oncall_shifts_from_ical(schedule, date, user_timezone="UTC", with_em user_timezone_offset = timezone.datetime.now().astimezone(pytz.timezone(user_timezone)).utcoffset() datetime_min = timezone.datetime.combine(date, datetime.time.min) + timezone.timedelta(milliseconds=1) datetime_start = (datetime_min - user_timezone_offset).astimezone(pytz.UTC) - datetime_end = datetime_start + timezone.timedelta(hours=23, minutes=59, seconds=59) + datetime_end = datetime_start + timezone.timedelta(days=days - 1, hours=23, minutes=59, seconds=59) result_datetime = [] result_date = [] @@ -137,6 +140,7 @@ def list_of_oncall_shifts_from_ical(schedule, date, user_timezone="UTC", with_em "source": None, "calendar_type": None, "is_gap": True, + "shift_uuid": None, } ) result = sorted(result_datetime, key=lambda dt: dt["start"]) + result_date @@ -150,7 +154,7 @@ def get_shifts_dict(calendar, calendar_type, schedule, datetime_start, datetime_ result_date = [] for event in events: priority = parse_priority_from_string(event.get(ICAL_SUMMARY, "[L0]")) - source = parse_source_from_string(event.get(ICAL_UID)) + uuid, source = parse_event_uid(event.get(ICAL_UID)) users = get_users_from_ical_event(event, schedule.organization) # Define on-call shift out of ical event that has the actual user if len(users) > 0 or with_empty_shifts: @@ -166,6 +170,7 @@ def get_shifts_dict(calendar, calendar_type, schedule, datetime_start, datetime_ "priority": priority, "source": source, "calendar_type": calendar_type, + "shift_uuid": uuid, } ) else: @@ -180,13 +185,15 @@ def get_shifts_dict(calendar, calendar_type, schedule, datetime_start, datetime_ "priority": priority, "source": source, "calendar_type": calendar_type, + "shift_uuid": uuid, } ) return result_datetime, result_date EmptyShift = namedtuple( - "EmptyShift", ["start", "end", "summary", "description", "attendee", "all_day", "calendar_type", "calendar_tz"] + "EmptyShift", + ["start", "end", "summary", "description", "attendee", "all_day", "calendar_type", "calendar_tz", "shift_uuid"], ) @@ -230,6 +237,7 @@ def list_of_empty_shifts_in_schedule(schedule, start_date, end_date): summary = event.get(ICAL_SUMMARY, "") description = event.get(ICAL_DESCRIPTION, "") attendee = event.get(ICAL_ATTENDEE, "") + uuid, _ = parse_event_uid(event.get(ICAL_UID)) event_hash = hash(f"{event[ICAL_UID]}{summary}{description}{attendee}") if event_hash in checked_events: @@ -257,6 +265,7 @@ def list_of_empty_shifts_in_schedule(schedule, start_date, end_date): all_day=all_day, calendar_type=calendar_type, calendar_tz=calendar_tz, + shift_uuid=uuid, ) ) empty_shifts.extend(empty_shifts_per_calendar) @@ -330,17 +339,16 @@ def parse_priority_from_string(string): return priority -def parse_source_from_string(string): - CustomOnCallShift = apps.get_model("schedules", "CustomOnCallShift") - split_string = string.split("-") +def parse_event_uid(string): + uuid = None source_verbal = None - if len(split_string) >= 2 and split_string[0] == "amixr": - regex = re.compile(r"^S(\d)$") - source = re.findall(regex, split_string[-1]) - if len(source) > 0: - source = int(source[0]) - source_verbal = CustomOnCallShift.SOURCE_CHOICES[source][1] - return source_verbal + match = RE_EVENT_UID.match(string) + if match: + uuid, _, _, source = match.groups() + source = int(source) + CustomOnCallShift = apps.get_model("schedules", "CustomOnCallShift") + source_verbal = CustomOnCallShift.SOURCE_CHOICES[source][1] + return uuid, source_verbal def get_usernames_from_ical_event(event): From 87fda3caf67b85b0ca5ffa5da5ee86216cd7ee8f Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Wed, 6 Jul 2022 15:47:21 -0300 Subject: [PATCH 3/5] Use shift public key in events --- engine/apps/api/tests/test_schedules.py | 16 +++-- engine/apps/api/views/schedule.py | 4 +- .../public_api/serializers/schedules_web.py | 3 +- engine/apps/schedules/ical_utils.py | 36 ++++++++---- .../schedules/models/custom_on_call_shift.py | 2 +- .../apps/schedules/models/on_call_schedule.py | 58 ++----------------- .../apps/schedules/tests/test_ical_utils.py | 21 ++++++- 7 files changed, 67 insertions(+), 73 deletions(-) diff --git a/engine/apps/api/tests/test_schedules.py b/engine/apps/api/tests/test_schedules.py index b0e61d54..c788d521 100644 --- a/engine/apps/api/tests/test_schedules.py +++ b/engine/apps/api/tests/test_schedules.py @@ -429,7 +429,9 @@ def test_events_calendar( "calendar_type": OnCallSchedule.PRIMARY, "is_empty": False, "is_gap": False, - "shift_uuid": str(on_call_shift.uuid), + "shift": { + "pk": on_call_shift.public_primary_key, + }, } ], } @@ -490,7 +492,9 @@ def test_filter_events_calendar( "calendar_type": OnCallSchedule.PRIMARY, "is_empty": False, "is_gap": False, - "shift_uuid": str(on_call_shift.uuid), + "shift": { + "pk": on_call_shift.public_primary_key, + }, }, { "all_day": False, @@ -502,7 +506,9 @@ def test_filter_events_calendar( "calendar_type": OnCallSchedule.PRIMARY, "is_empty": False, "is_gap": False, - "shift_uuid": str(on_call_shift.uuid), + "shift": { + "pk": on_call_shift.public_primary_key, + }, }, ], } @@ -566,7 +572,9 @@ def test_filter_events_range_calendar( "calendar_type": OnCallSchedule.PRIMARY, "is_empty": False, "is_gap": False, - "shift_uuid": str(on_call_shift.uuid), + "shift": { + "pk": on_call_shift.public_primary_key, + }, } ], } diff --git a/engine/apps/api/views/schedule.py b/engine/apps/api/views/schedule.py index 819950eb..4839ecd8 100644 --- a/engine/apps/api/views/schedule.py +++ b/engine/apps/api/views/schedule.py @@ -217,7 +217,9 @@ class ScheduleView( "calendar_type": shift["calendar_type"], "is_empty": len(shift["users"]) == 0 and not is_gap, "is_gap": is_gap, - "shift_uuid": shift["shift_uuid"], + "shift": { + "pk": shift["shift_pk"], + }, } events.append(shift_json) diff --git a/engine/apps/public_api/serializers/schedules_web.py b/engine/apps/public_api/serializers/schedules_web.py index 2ed1eed8..4d9c6b84 100644 --- a/engine/apps/public_api/serializers/schedules_web.py +++ b/engine/apps/public_api/serializers/schedules_web.py @@ -1,5 +1,4 @@ import pytz -from django.utils import timezone from rest_framework import serializers from apps.public_api.serializers.schedules_base import ScheduleBaseSerializer @@ -35,7 +34,7 @@ class ScheduleWebSerializer(ScheduleBaseSerializer): def validate_time_zone(self, tz): try: - timezone.now().astimezone(pytz.timezone(tz)) + pytz.timezone(tz) except pytz.exceptions.UnknownTimeZoneError: raise BadRequest(detail="Invalid time zone") return tz diff --git a/engine/apps/schedules/ical_utils.py b/engine/apps/schedules/ical_utils.py index 8bd1c967..65b6d574 100644 --- a/engine/apps/schedules/ical_utils.py +++ b/engine/apps/schedules/ical_utils.py @@ -75,7 +75,8 @@ ICAL_DESCRIPTION = "DESCRIPTION" ICAL_ATTENDEE = "ATTENDEE" ICAL_UID = "UID" RE_PRIORITY = re.compile(r"^\[L(\d)\]") -RE_EVENT_UID = re.compile(r"amixr-([\w\d-]+)-U(\d+)-E(\d+)-S(\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) @@ -140,7 +141,7 @@ def list_of_oncall_shifts_from_ical( "source": None, "calendar_type": None, "is_gap": True, - "shift_uuid": None, + "shift_pk": None, } ) result = sorted(result_datetime, key=lambda dt: dt["start"]) + result_date @@ -154,7 +155,7 @@ def get_shifts_dict(calendar, calendar_type, schedule, datetime_start, datetime_ result_date = [] for event in events: priority = parse_priority_from_string(event.get(ICAL_SUMMARY, "[L0]")) - uuid, source = parse_event_uid(event.get(ICAL_UID)) + pk, source = parse_event_uid(event.get(ICAL_UID)) users = get_users_from_ical_event(event, schedule.organization) # Define on-call shift out of ical event that has the actual user if len(users) > 0 or with_empty_shifts: @@ -170,7 +171,7 @@ def get_shifts_dict(calendar, calendar_type, schedule, datetime_start, datetime_ "priority": priority, "source": source, "calendar_type": calendar_type, - "shift_uuid": uuid, + "shift_pk": pk, } ) else: @@ -185,7 +186,7 @@ def get_shifts_dict(calendar, calendar_type, schedule, datetime_start, datetime_ "priority": priority, "source": source, "calendar_type": calendar_type, - "shift_uuid": uuid, + "shift_pk": pk, } ) return result_datetime, result_date @@ -193,7 +194,7 @@ def get_shifts_dict(calendar, calendar_type, schedule, datetime_start, datetime_ EmptyShift = namedtuple( "EmptyShift", - ["start", "end", "summary", "description", "attendee", "all_day", "calendar_type", "calendar_tz", "shift_uuid"], + ["start", "end", "summary", "description", "attendee", "all_day", "calendar_type", "calendar_tz", "shift_pk"], ) @@ -237,7 +238,7 @@ def list_of_empty_shifts_in_schedule(schedule, start_date, end_date): summary = event.get(ICAL_SUMMARY, "") description = event.get(ICAL_DESCRIPTION, "") attendee = event.get(ICAL_ATTENDEE, "") - uuid, _ = parse_event_uid(event.get(ICAL_UID)) + pk, _ = parse_event_uid(event.get(ICAL_UID)) event_hash = hash(f"{event[ICAL_UID]}{summary}{description}{attendee}") if event_hash in checked_events: @@ -265,7 +266,7 @@ def list_of_empty_shifts_in_schedule(schedule, start_date, end_date): all_day=all_day, calendar_type=calendar_type, calendar_tz=calendar_tz, - shift_uuid=uuid, + shift_pk=pk, ) ) empty_shifts.extend(empty_shifts_per_calendar) @@ -340,15 +341,26 @@ def parse_priority_from_string(string): def parse_event_uid(string): - uuid = None + pk = None + source = None source_verbal = None - match = RE_EVENT_UID.match(string) + + match = RE_EVENT_UID_V2.match(string) if match: - uuid, _, _, source = match.groups() + _, pk, _, _, source = match.groups() + else: + # eventually this path would be automatically deprecated + # once all ical representations are refreshed + match = RE_EVENT_UID_V1.match(string) + if match: + _, _, _, source = match.groups() + + if source is not None: source = int(source) CustomOnCallShift = apps.get_model("schedules", "CustomOnCallShift") source_verbal = CustomOnCallShift.SOURCE_CHOICES[source][1] - return uuid, source_verbal + + return pk, source_verbal def get_usernames_from_ical_event(event): diff --git a/engine/apps/schedules/models/custom_on_call_shift.py b/engine/apps/schedules/models/custom_on_call_shift.py index aa721d28..0f2753da 100644 --- a/engine/apps/schedules/models/custom_on_call_shift.py +++ b/engine/apps/schedules/models/custom_on_call_shift.py @@ -230,7 +230,7 @@ class CustomOnCallShift(models.Model): def generate_ical(self, user, start, user_counter, counter=1, time_zone="UTC"): # create event for each user in a list because we can't parse multiple users from ical summary event = Event() - event["uid"] = f"amixr-{self.uuid}-U{user_counter}-E{counter}-S{self.source}" + event["uid"] = f"oncall-{self.uuid}-PK{self.public_primary_key}-U{user_counter}-E{counter}-S{self.source}" 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)) diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index 9e84d8d3..05493789 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -213,10 +213,14 @@ class OnCallSchedule(PolymorphicModel): raise NotImplementedError def _drop_primary_ical_file(self): - raise NotImplementedError + self.prev_ical_file_primary = self.cached_ical_file_primary + self.cached_ical_file_primary = None + self.save(update_fields=["cached_ical_file_primary", "prev_ical_file_primary"]) def _drop_overrides_ical_file(self): - raise NotImplementedError + self.prev_ical_file_overrides = self.cached_ical_file_overrides + self.cached_ical_file_overrides = None + self.save(update_fields=["cached_ical_file_overrides", "prev_ical_file_overrides"]) class OnCallScheduleICal(OnCallSchedule): @@ -255,26 +259,6 @@ class OnCallScheduleICal(OnCallSchedule): cached_ical_file = self.cached_ical_file_overrides return cached_ical_file - def _drop_primary_ical_file(self): - self.prev_ical_file_primary = self.cached_ical_file_primary - self.cached_ical_file_primary = None - self.save( - update_fields=[ - "cached_ical_file_primary", - "prev_ical_file_primary", - ] - ) - - def _drop_overrides_ical_file(self): - self.prev_ical_file_overrides = self.cached_ical_file_overrides - self.cached_ical_file_overrides = None - self.save( - update_fields=[ - "cached_ical_file_overrides", - "prev_ical_file_overrides", - ] - ) - def _refresh_primary_ical_file(self): self.prev_ical_file_primary = self.cached_ical_file_primary if self.ical_url_primary is not None: @@ -351,26 +335,6 @@ class OnCallScheduleCalendar(OnCallSchedule): ) self.save(update_fields=["cached_ical_file_overrides", "prev_ical_file_overrides", "ical_file_error_overrides"]) - def _drop_primary_ical_file(self): - self.prev_ical_file_primary = self.cached_ical_file_primary - self.cached_ical_file_primary = None - self.save( - update_fields=[ - "cached_ical_file_primary", - "prev_ical_file_primary", - ] - ) - - def _drop_overrides_ical_file(self): - self.prev_ical_file_overrides = self.cached_ical_file_overrides - self.cached_ical_file_overrides = None - self.save( - update_fields=[ - "cached_ical_file_overrides", - "prev_ical_file_overrides", - ] - ) - def _generate_ical_file_primary(self): """ Generate iCal events file from custom on-call shifts (created via API) @@ -438,11 +402,6 @@ class OnCallScheduleWeb(OnCallSchedule): self.cached_ical_file_primary = self._generate_ical_file_primary() self.save(update_fields=["cached_ical_file_primary", "prev_ical_file_primary"]) - def _drop_primary_ical_file(self): - self.prev_ical_file_primary = self.cached_ical_file_primary - self.cached_ical_file_primary = None - self.save(update_fields=["cached_ical_file_primary", "prev_ical_file_primary"]) - @cached_property def _ical_file_overrides(self): """Return cached ical file with iCal events from custom on-call overrides shifts.""" @@ -455,8 +414,3 @@ class OnCallScheduleWeb(OnCallSchedule): self.prev_ical_file_overrides = self.cached_ical_file_overrides self.cached_ical_file_overrides = self._generate_ical_file_overrides() self.save(update_fields=["cached_ical_file_overrides", "prev_ical_file_overrides"]) - - def _drop_overrides_ical_file(self): - self.prev_ical_file_overrides = self.cached_ical_file_overrides - self.cached_ical_file_overrides = None - self.save(update_fields=["cached_ical_file_overrides", "prev_ical_file_overrides"]) diff --git a/engine/apps/schedules/tests/test_ical_utils.py b/engine/apps/schedules/tests/test_ical_utils.py index 8032334d..2a737df2 100644 --- a/engine/apps/schedules/tests/test_ical_utils.py +++ b/engine/apps/schedules/tests/test_ical_utils.py @@ -1,7 +1,9 @@ +from uuid import uuid4 + import pytest from django.utils import timezone -from apps.schedules.ical_utils import list_users_to_notify_from_ical, users_in_ical +from apps.schedules.ical_utils import list_users_to_notify_from_ical, parse_event_uid, users_in_ical from apps.schedules.models import CustomOnCallShift, OnCallScheduleCalendar from common.constants.role import Role @@ -58,3 +60,20 @@ def test_list_users_to_notify_from_ical_viewers_inclusion( else: assert len(users_on_call) == 1 assert set(users_on_call) == {user} + + +def test_parse_event_uid_v1(): + uuid = uuid4() + event_uid = f"amixr-{uuid}-U1-E2-S1" + pk, source = parse_event_uid(event_uid) + assert pk is None + assert source == "api" + + +def test_parse_event_uid_v2(): + uuid = uuid4() + pk_value = "OABCDEF12345" + event_uid = f"oncall-{uuid}-PK{pk_value}-U3-E1-S2" + pk, source = parse_event_uid(event_uid) + assert pk == pk_value + assert source == "slack" From 835fdaa3b7b29c46f82b1d870bcedf2e333d5dca Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Thu, 7 Jul 2022 14:38:22 -0300 Subject: [PATCH 4/5] Do not allow override shifts in calendar schedules --- .../serializers/schedules_calendar.py | 2 + .../apps/public_api/tests/test_schedules.py | 75 +++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/engine/apps/public_api/serializers/schedules_calendar.py b/engine/apps/public_api/serializers/schedules_calendar.py index 55c2d2a2..23f7f674 100644 --- a/engine/apps/public_api/serializers/schedules_calendar.py +++ b/engine/apps/public_api/serializers/schedules_calendar.py @@ -51,6 +51,8 @@ class ScheduleCalendarSerializer(ScheduleBaseSerializer): for shift in shifts: if shift.team_id != team_id: raise BadRequest(detail="Shifts must be assigned to the same team as the schedule") + if shift.type == CustomOnCallShift.TYPE_OVERRIDE: + raise BadRequest(detail="Shifts of type override are not supported in this schedule") return shifts diff --git a/engine/apps/public_api/tests/test_schedules.py b/engine/apps/public_api/tests/test_schedules.py index 99411b9f..d1a34e04 100644 --- a/engine/apps/public_api/tests/test_schedules.py +++ b/engine/apps/public_api/tests/test_schedules.py @@ -367,6 +367,81 @@ def test_update_calendar_schedule_with_custom_event( assert response.json() == result +@pytest.mark.django_db +def test_update_calendar_schedule_invalid_override( + make_organization_and_user_with_token, + make_schedule, + make_on_call_shift, +): + + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleCalendar, + ) + data = { + "start": timezone.now().replace(tzinfo=None, microsecond=0), + "duration": timezone.timedelta(seconds=10800), + } + on_call_shift = make_on_call_shift(organization=organization, shift_type=CustomOnCallShift.TYPE_OVERRIDE, **data) + + url = reverse("api-public:schedules-detail", kwargs={"pk": schedule.public_primary_key}) + + data = { + "shifts": [on_call_shift.public_primary_key], + } + + response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"detail": "Shifts of type override are not supported in this schedule"} + + +@pytest.mark.django_db +def test_update_web_schedule_with_override( + make_organization_and_user_with_token, + make_schedule, + make_on_call_shift, +): + + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleWeb, + ) + data = { + "start": timezone.now().replace(tzinfo=None, microsecond=0), + "duration": timezone.timedelta(seconds=10800), + } + on_call_shift = make_on_call_shift(organization=organization, shift_type=CustomOnCallShift.TYPE_OVERRIDE, **data) + + url = reverse("api-public:schedules-detail", kwargs={"pk": schedule.public_primary_key}) + + data = { + "shifts": [on_call_shift.public_primary_key], + } + + response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + expected = { + "id": schedule.public_primary_key, + "team_id": None, + "name": schedule.name, + "type": "web", + "time_zone": schedule.time_zone, + "on_call_now": [], + "shifts": data["shifts"], + "slack": { + "channel_id": None, + "user_group_id": None, + }, + } + assert response.status_code == status.HTTP_200_OK + assert response.json() == expected + + @pytest.mark.django_db def test_delete_calendar_schedule( make_organization_and_user_with_token, From d18d78a48f224c53827b55772edf308f077ee17e Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Fri, 8 Jul 2022 12:03:18 -0300 Subject: [PATCH 5/5] Disable web schedule create/update from public API --- .../apps/public_api/tests/test_schedules.py | 59 ++----------------- engine/apps/public_api/views/schedules.py | 9 ++- 2 files changed, 14 insertions(+), 54 deletions(-) diff --git a/engine/apps/public_api/tests/test_schedules.py b/engine/apps/public_api/tests/test_schedules.py index d1a34e04..52acd29f 100644 --- a/engine/apps/public_api/tests/test_schedules.py +++ b/engine/apps/public_api/tests/test_schedules.py @@ -198,24 +198,8 @@ def test_create_web_schedule(make_organization_and_user_with_token): } response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") - schedule = OnCallSchedule.objects.get(public_primary_key=response.data["id"]) - - result = { - "id": schedule.public_primary_key, - "team_id": None, - "name": schedule.name, - "type": "web", - "time_zone": "Europe/Moscow", - "on_call_now": [], - "shifts": [], - "slack": { - "channel_id": None, - "user_group_id": None, - }, - } - - assert response.status_code == status.HTTP_201_CREATED - assert response.json() == result + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"detail": "Web schedule creation is not enabled through API"} @pytest.mark.django_db @@ -246,26 +230,8 @@ def test_update_web_schedule( assert schedule.time_zone != data["time_zone"] response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") - - result = { - "id": schedule.public_primary_key, - "team_id": None, - "name": data["name"], - "type": "web", - "time_zone": data["time_zone"], - "on_call_now": [], - "shifts": [], - "slack": { - "channel_id": "SLACKCHANNELID", - "user_group_id": None, - }, - } - - assert response.status_code == status.HTTP_200_OK - schedule.refresh_from_db() - assert schedule.name == data["name"] - assert schedule.time_zone == data["time_zone"] - assert response.json() == result + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"detail": "Web schedule update is not enabled through API"} @pytest.mark.django_db @@ -425,21 +391,8 @@ def test_update_web_schedule_with_override( } response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") - expected = { - "id": schedule.public_primary_key, - "team_id": None, - "name": schedule.name, - "type": "web", - "time_zone": schedule.time_zone, - "on_call_now": [], - "shifts": data["shifts"], - "slack": { - "channel_id": None, - "user_group_id": None, - }, - } - assert response.status_code == status.HTTP_200_OK - assert response.json() == expected + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"detail": "Web schedule update is not enabled through API"} @pytest.mark.django_db diff --git a/engine/apps/public_api/views/schedules.py b/engine/apps/public_api/views/schedules.py index 946463cb..e52d72fc 100644 --- a/engine/apps/public_api/views/schedules.py +++ b/engine/apps/public_api/views/schedules.py @@ -11,9 +11,10 @@ from apps.public_api.custom_renderers import CalendarRenderer from apps.public_api.serializers import PolymorphicScheduleSerializer, PolymorphicScheduleUpdateSerializer from apps.public_api.throttlers.user_throttle import UserThrottle from apps.schedules.ical_utils import ical_export_from_schedule -from apps.schedules.models import OnCallSchedule +from apps.schedules.models import OnCallSchedule, OnCallScheduleWeb from apps.slack.tasks import update_slack_user_group_for_schedules from apps.user_management.organization_log_creator import OrganizationLogType, create_organization_log +from common.api_helpers.exceptions import BadRequest from common.api_helpers.filters import ByTeamFilter from common.api_helpers.mixins import RateLimitHeadersMixin, UpdateSerializerMixin from common.api_helpers.paginators import FiftyPageSizePaginator @@ -55,6 +56,9 @@ class OnCallScheduleChannelView(RateLimitHeadersMixin, UpdateSerializerMixin, Mo raise NotFound def perform_create(self, serializer): + if serializer.validated_data["type"] == "web": + raise BadRequest(detail="Web schedule creation is not enabled through API") + serializer.save() instance = serializer.instance @@ -67,6 +71,9 @@ class OnCallScheduleChannelView(RateLimitHeadersMixin, UpdateSerializerMixin, Mo create_organization_log(organization, user, OrganizationLogType.TYPE_SCHEDULE_CREATED, description) def perform_update(self, serializer): + if isinstance(serializer.instance, OnCallScheduleWeb): + raise BadRequest(detail="Web schedule update is not enabled through API") + organization = self.request.auth.organization user = self.request.user old_state = serializer.instance.repr_settings_for_client_side_logging