From 817693b1c0ebb1aaecaa463b7120f2dd9212d4d8 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Mon, 27 Jun 2022 17:26:53 -0300 Subject: [PATCH 1/6] Add hourly support to custom shifts frequency --- docs/sources/oncall-api-reference/on_call_shifts.md | 2 +- engine/apps/schedules/models/custom_on_call_shift.py | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/sources/oncall-api-reference/on_call_shifts.md b/docs/sources/oncall-api-reference/on_call_shifts.md index 31eee871..07e5869b 100644 --- a/docs/sources/oncall-api-reference/on_call_shifts.md +++ b/docs/sources/oncall-api-reference/on_call_shifts.md @@ -53,7 +53,7 @@ The above command returns JSON structured in the following way: | `level` | No | Optional | Priority level. The higher the value, the higher the priority. If two events overlap in one schedule, Grafana OnCall will choose the event with higher level. For example: Alex is on-call from 8AM till 11AM with level 1, Bob is on-call from 9AM till 11AM with level 2. At 10AM Grafana OnCall will notify Bob. At 8AM OnCall will notify Alex. | | `start` | No | Yes | Start time of the on-call shift. This parameter takes a date format as `yyyy-MM-dd'T'HH:mm:ss` (for example "2020-09-05T08:00:00"). | | `duration` | No | Yes | Duration of the event. | -| `frequency` | No | If type = `recurrent_event` or `rolling_users` | One of: `daily`, `weekly`, `monthly`. | +| `frequency` | No | If type = `recurrent_event` or `rolling_users` | One of: `hourly`, `daily`, `weekly`, `monthly`. | | `interval` | No | Optional | This parameter takes a positive integer that represents the intervals that the recurrence rule repeats. | | `week_start` | No | Optional | Start day of the week in iCal format. One of: `SU` (Sunday), `MO` (Monday), `TU` (Tuesday), `WE` (Wednesday), `TH` (Thursday), `FR` (Friday), `SA` (Saturday). Default: `SU`. | | `by_day` | No | Optional | List of days in iCal format. Valid values are: `SU`, `MO`, `TU`, `WE`, `TH`, `FR`, `SA`. | diff --git a/engine/apps/schedules/models/custom_on_call_shift.py b/engine/apps/schedules/models/custom_on_call_shift.py index ffe09511..e0e4ca77 100644 --- a/engine/apps/schedules/models/custom_on_call_shift.py +++ b/engine/apps/schedules/models/custom_on_call_shift.py @@ -40,15 +40,18 @@ class CustomOnCallShift(models.Model): FREQUENCY_DAILY, FREQUENCY_WEEKLY, FREQUENCY_MONTHLY, - ) = range(3) + FREQUENCY_HOURLY, + ) = range(4) FREQUENCY_CHOICES = ( + (FREQUENCY_HOURLY, "Hourly"), (FREQUENCY_DAILY, "Daily"), (FREQUENCY_WEEKLY, "Weekly"), (FREQUENCY_MONTHLY, "Monthly"), ) PUBLIC_FREQUENCY_CHOICES_MAP = { + FREQUENCY_HOURLY: "hourly", FREQUENCY_DAILY: "daily", FREQUENCY_WEEKLY: "weekly", FREQUENCY_MONTHLY: "monthly", @@ -247,7 +250,9 @@ class CustomOnCallShift(models.Model): next_event_start = current_event_start ONE_DAY = 1 - if self.frequency == CustomOnCallShift.FREQUENCY_DAILY: + if self.frequency == CustomOnCallShift.FREQUENCY_HOURLY: + next_event_start = current_event_start + timezone.timedelta(hours=1) + elif self.frequency == CustomOnCallShift.FREQUENCY_DAILY: # test daily with byday next_event_start = current_event_start + timezone.timedelta(days=ONE_DAY) elif self.frequency == CustomOnCallShift.FREQUENCY_WEEKLY: From 471e59c0b071590667e9114d89d487a321a267d1 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Tue, 28 Jun 2022 10:07:29 -0300 Subject: [PATCH 2/6] Add db migration for shift frequency choices update --- .../0003_alter_customoncallshift_frequency.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 engine/apps/schedules/migrations/0003_alter_customoncallshift_frequency.py diff --git a/engine/apps/schedules/migrations/0003_alter_customoncallshift_frequency.py b/engine/apps/schedules/migrations/0003_alter_customoncallshift_frequency.py new file mode 100644 index 00000000..239ce026 --- /dev/null +++ b/engine/apps/schedules/migrations/0003_alter_customoncallshift_frequency.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.5 on 2022-06-28 13:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('schedules', '0002_squashed_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customoncallshift', + name='frequency', + field=models.IntegerField(choices=[(3, 'Hourly'), (0, 'Daily'), (1, 'Weekly'), (2, 'Monthly')], default=None, null=True), + ), + ] From c8b8449afc81dfd45cb4b5bbc3507eea09efb7f2 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Tue, 28 Jun 2022 11:21:36 -0300 Subject: [PATCH 3/6] Add UNTIL support for shifts recurrence rules --- .../oncall-api-reference/on_call_shifts.md | 7 ++--- .../public_api/serializers/on_call_shifts.py | 16 +++++++++-- .../public_api/tests/test_on_call_shifts.py | 12 ++++++++- .../0004_customoncallshift_until.py | 18 +++++++++++++ .../schedules/models/custom_on_call_shift.py | 7 ++++- .../tests/test_custom_on_call_shift.py | 27 +++++++++++++++++++ 6 files changed, 80 insertions(+), 7 deletions(-) create mode 100644 engine/apps/schedules/migrations/0004_customoncallshift_until.py diff --git a/docs/sources/oncall-api-reference/on_call_shifts.md b/docs/sources/oncall-api-reference/on_call_shifts.md index 07e5869b..8f43975d 100644 --- a/docs/sources/oncall-api-reference/on_call_shifts.md +++ b/docs/sources/oncall-api-reference/on_call_shifts.md @@ -55,6 +55,7 @@ The above command returns JSON structured in the following way: | `duration` | No | Yes | Duration of the event. | | `frequency` | No | If type = `recurrent_event` or `rolling_users` | One of: `hourly`, `daily`, `weekly`, `monthly`. | | `interval` | No | Optional | This parameter takes a positive integer that represents the intervals that the recurrence rule repeats. | +| `until` | No | Optional | When the recurrence rule ends (endless if None). This parameter takes a date format as `yyyy-MM-dd'T'HH:mm:ss` (for example "2020-09-05T08:00:00"). | | `week_start` | No | Optional | Start day of the week in iCal format. One of: `SU` (Sunday), `MO` (Monday), `TU` (Tuesday), `WE` (Wednesday), `TH` (Thursday), `FR` (Friday), `SA` (Saturday). Default: `SU`. | | `by_day` | No | Optional | List of days in iCal format. Valid values are: `SU`, `MO`, `TU`, `WE`, `TH`, `FR`, `SA`. | | `by_month` | No | Optional | List of months. Valid values are `1` to `12`. | @@ -72,7 +73,7 @@ Please see [RFC 5545](https://tools.ietf.org/html/rfc5545#section-3.3.10) for mo # Get OnCall shifts ```shell -curl "{{API_URL}}/api/v1/on_call_shifts/SBM7DV7BKFUYU/" \ +curl "{{API_URL}}/api/v1/on_call_shifts/OH3V5FYQEYJ6M/" \ --request GET \ --header "Authorization: meowmeowmeow" \ --header "Content-Type: application/json" \ @@ -159,7 +160,7 @@ The following available filter parameters should be provided as `GET` arguments: # Update OnCall shift ```shell -curl "{{API_URL}}/api/v1/on_call_shifts/S3Z477AHDXTMF/" \ +curl "{{API_URL}}/api/v1/on_call_shifts/OH3V5FYQEYJ6M/" \ --request PUT \ --header "Authorization: meowmeowmeow" \ --header "Content-Type: application/json" \ @@ -198,7 +199,7 @@ The above command returns JSON structured in the following way: # Delete OnCall shift ```shell -curl "{{API_URL}}/api/v1/on_call_shifts/S3Z477AHDXTMF/" \ +curl "{{API_URL}}/api/v1/on_call_shifts/OH3V5FYQEYJ6M/" \ --request DELETE \ --header "Authorization: meowmeowmeow" \ --header "Content-Type: application/json" diff --git a/engine/apps/public_api/serializers/on_call_shifts.py b/engine/apps/public_api/serializers/on_call_shifts.py index 29a148eb..97f4f34e 100644 --- a/engine/apps/public_api/serializers/on_call_shifts.py +++ b/engine/apps/public_api/serializers/on_call_shifts.py @@ -105,6 +105,7 @@ class CustomOnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer "duration", "frequency", "interval", + "until", "week_start", "by_day", "by_month", @@ -214,12 +215,18 @@ class CustomOnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer if type == CustomOnCallShift.TYPE_ROLLING_USERS_EVENT and index is None: raise BadRequest(detail="Field 'start_rotation_from_user_index' is required for this on-call shift type") - def _validate_start(self, start): + def _validate_date_format(self, value): try: - time.strptime(start, "%Y-%m-%dT%H:%M:%S") + time.strptime(value, "%Y-%m-%dT%H:%M:%S") except (TypeError, ValueError): raise BadRequest(detail="Invalid datetime format, should be \"yyyy-mm-dd'T'hh:mm:ss\"") + def _validate_start(self, start): + self._validate_date_format(start) + + def _validate_until(self, until): + self._validate_date_format(until) + def to_internal_value(self, data): if data.get("users", []) is None: # terraform case data["users"] = [] @@ -229,6 +236,8 @@ class CustomOnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer data["source"] = CustomOnCallShift.SOURCE_API if data.get("start") is not None: self._validate_start(data["start"]) + if data.get("until") is not None: + self._validate_until(data["until"]) result = super().to_internal_value(data) return result @@ -236,6 +245,8 @@ class CustomOnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer result = super().to_representation(instance) result["duration"] = int(instance.duration.total_seconds()) result["start"] = instance.start.strftime("%Y-%m-%dT%H:%M:%S") + if instance.until is not None: + result["until"] = instance.until.strftime("%Y-%m-%dT%H:%M:%S") result = self._get_fields_to_represent(instance, result) return result @@ -245,6 +256,7 @@ class CustomOnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer CustomOnCallShift.TYPE_SINGLE_EVENT: [ "frequency", "interval", + "until", "by_day", "by_month", "by_monthday", 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 c5bb99d1..3910cb94 100644 --- a/engine/apps/public_api/tests/test_on_call_shifts.py +++ b/engine/apps/public_api/tests/test_on_call_shifts.py @@ -35,6 +35,10 @@ invalid_field_data_7 = { "type": "invalid_type", } +invalid_field_data_8 = { + "until": "not-a-date", +} + @pytest.mark.django_db def test_get_on_call_shift(make_organization_and_user_with_token, make_on_call_shift, make_schedule): @@ -80,17 +84,20 @@ def test_create_on_call_shift(make_organization_and_user_with_token): url = reverse("api-public:on_call_shifts-list") + start = datetime.datetime.now() + until = start + datetime.timedelta(days=30) data = { "team_id": None, "name": "test name", "type": "recurrent_event", "level": 1, - "start": datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S"), + "start": start.strftime("%Y-%m-%dT%H:%M:%S"), "duration": 10800, "users": [user.public_primary_key], "week_start": "MO", "frequency": "weekly", "interval": 2, + "until": until.strftime("%Y-%m-%dT%H:%M:%S"), "by_day": ["MO", "WE", "FR"], } @@ -108,6 +115,7 @@ def test_create_on_call_shift(make_organization_and_user_with_token): "duration": data["duration"], "frequency": data["frequency"], "interval": data["interval"], + "until": data["until"], "week_start": data["week_start"], "by_day": data["by_day"], "users": [user.public_primary_key], @@ -163,6 +171,7 @@ def test_update_on_call_shift(make_organization_and_user_with_token, make_on_cal "duration": data_to_update["duration"], "frequency": "weekly", "interval": on_call_shift.interval, + "until": None, "week_start": "SU", "by_day": data_to_update["by_day"], "users": [user.public_primary_key], @@ -190,6 +199,7 @@ def test_update_on_call_shift(make_organization_and_user_with_token, make_on_cal invalid_field_data_5, invalid_field_data_6, invalid_field_data_7, + invalid_field_data_8, ], ) def test_update_on_call_shift_invalid_field(make_organization_and_user_with_token, make_on_call_shift, data_to_update): diff --git a/engine/apps/schedules/migrations/0004_customoncallshift_until.py b/engine/apps/schedules/migrations/0004_customoncallshift_until.py new file mode 100644 index 00000000..46c3c8e2 --- /dev/null +++ b/engine/apps/schedules/migrations/0004_customoncallshift_until.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.5 on 2022-06-28 13:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('schedules', '0003_alter_customoncallshift_frequency'), + ] + + operations = [ + migrations.AddField( + model_name='customoncallshift', + name='until', + field=models.DateTimeField(default=None, null=True), + ), + ] diff --git a/engine/apps/schedules/models/custom_on_call_shift.py b/engine/apps/schedules/models/custom_on_call_shift.py index e0e4ca77..7cb72ef5 100644 --- a/engine/apps/schedules/models/custom_on_call_shift.py +++ b/engine/apps/schedules/models/custom_on_call_shift.py @@ -147,6 +147,8 @@ class CustomOnCallShift(models.Model): interval = models.IntegerField(default=None, null=True) # every n days/months - ical format + until = models.DateTimeField(default=None, null=True) # if set, when recurrence ends + # week_start in ical format week_start = models.IntegerField(choices=WEEKDAY_CHOICES, default=SUNDAY) # for weekly events @@ -193,7 +195,7 @@ class CustomOnCallShift(models.Model): 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}, " - f"by monthday: {self.by_monthday}" + f"by monthday: {self.by_monthday}, until: {self.until.isoformat() if self.until else None}" ) return result @@ -297,6 +299,9 @@ class CustomOnCallShift(models.Model): rules["bymonthday"] = self.by_monthday if self.week_start is not None: rules["wkst"] = CustomOnCallShift.ICAL_WEEKDAY_MAP[self.week_start] + if self.until is not None: + time_zone = self.time_zone if self.time_zone is not None else "UTC" + rules["until"] = self.convert_dt_to_schedule_timezone(self.until, time_zone) return rules @cached_property 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 0a6468a9..c2fb8a26 100644 --- a/engine/apps/schedules/tests/test_custom_on_call_shift.py +++ b/engine/apps/schedules/tests/test_custom_on_call_shift.py @@ -199,3 +199,30 @@ def test_get_oncall_users_for_multiple_schedules( assert schedules.get_oncall_users(events_datetime=now + timezone.timedelta(minutes=30, seconds=1)) == [user_3] assert schedules.get_oncall_users(events_datetime=now + timezone.timedelta(minutes=40, seconds=1)) == [] + + +@pytest.mark.django_db +def test_shift_convert_to_ical(make_organization_and_user, make_on_call_shift): + organization, user = make_organization_and_user() + + date = timezone.now().replace(tzinfo=None, microsecond=0) + until = date + timezone.timedelta(days=30) + + data = { + "priority_level": 1, + "start": date, + "duration": timezone.timedelta(seconds=10800), + "frequency": CustomOnCallShift.FREQUENCY_HOURLY, + "interval": 1, + "until": until, + } + + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_RECURRENT_EVENT, **data + ) + on_call_shift.users.add(user) + + ical_data = on_call_shift.convert_to_ical() + ical_rrule_until = on_call_shift.until.strftime("%Y%m%dT%H%M%S") + expected_rrule = f"RRULE:FREQ=HOURLY;UNTIL={ical_rrule_until}Z;INTERVAL=1;WKST=SU" + assert expected_rrule in ical_data From 77d27648d5e16e29650ee23d5b9f1196690996a6 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Tue, 5 Jul 2022 14:23:09 +0400 Subject: [PATCH 4/6] Add possibility to modify default route on integration create --- .../public_api/serializers/integrations.py | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/engine/apps/public_api/serializers/integrations.py b/engine/apps/public_api/serializers/integrations.py index 090523a2..899f6260 100644 --- a/engine/apps/public_api/serializers/integrations.py +++ b/engine/apps/public_api/serializers/integrations.py @@ -1,4 +1,5 @@ from django.core.exceptions import ObjectDoesNotExist +from django.db import transaction from jinja2 import TemplateSyntaxError from rest_framework import fields, serializers @@ -65,18 +66,25 @@ class IntegrationSerializer(EagerLoadingMixin, serializers.ModelSerializer, Main def create(self, validated_data): validated_data = self._correct_validated_data(validated_data) - validated_data.pop("default_route", None) + default_route_data = validated_data.pop("default_route", None) organization = self.context["request"].auth.organization integration = validated_data.get("integration") if integration == AlertReceiveChannel.INTEGRATION_GRAFANA_ALERTING: connection_error = GrafanaAlertingSyncManager.check_for_connection_errors(organization) if connection_error: raise serializers.ValidationError(connection_error) - instance = AlertReceiveChannel.create( - **validated_data, - author=self.context["request"].user, - organization=organization, - ) + with transaction.atomic(): + instance = AlertReceiveChannel.create( + **validated_data, + author=self.context["request"].user, + organization=organization, + ) + if default_route_data: + serializer = DefaultChannelFilterSerializer( + instance.default_channel_filter, default_route_data, context=self.context + ) + serializer.is_valid(raise_exception=True) + serializer.save() return instance def validate(self, attrs): @@ -175,9 +183,10 @@ class IntegrationUpdateSerializer(IntegrationSerializer): def update(self, instance, validated_data): validated_data = self._correct_validated_data(validated_data) - default_route_data = validated_data.pop("default_route", {}) + default_route_data = validated_data.pop("default_route", None) default_route = instance.default_channel_filter - serializer = DefaultChannelFilterSerializer(default_route, default_route_data, context=self.context) - serializer.is_valid(raise_exception=True) - serializer.save() + if default_route_data: + serializer = DefaultChannelFilterSerializer(default_route, default_route_data, context=self.context) + serializer.is_valid(raise_exception=True) + serializer.save() return super().update(instance, validated_data) From 53dcef0b186f2d2a8daa9c636d42f5bf9c044532 Mon Sep 17 00:00:00 2001 From: Vadim Stepanov Date: Tue, 5 Jul 2022 14:29:47 +0100 Subject: [PATCH 5/6] fix parsing ICal attendees from PD schedules (#198) --- engine/apps/schedules/ical_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/engine/apps/schedules/ical_utils.py b/engine/apps/schedules/ical_utils.py index cb0bc342..8a855d88 100644 --- a/engine/apps/schedules/ical_utils.py +++ b/engine/apps/schedules/ical_utils.py @@ -351,7 +351,7 @@ def get_usernames_from_ical_event(event): if ICAL_DESCRIPTION in event: usernames_found.append(parse_username_from_string(event[ICAL_DESCRIPTION])) if ICAL_ATTENDEE in event: - if type(event[ICAL_ATTENDEE]) is str: + if isinstance(event[ICAL_ATTENDEE], str): # PagerDuty adds only one attendee and in this case event[ICAL_ATTENDEE] is string. # If several attendees were added to the event than event[ICAL_ATTENDEE] will be list # (E.g. several invited in Google cal). From 137388cb49fa0c6fd1c11ce1d0166a83a141f773 Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Wed, 6 Jul 2022 10:03:48 +0300 Subject: [PATCH 6/6] Update requirements.txt --- engine/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/engine/requirements.txt b/engine/requirements.txt index 2e8e11ef..ece1474c 100644 --- a/engine/requirements.txt +++ b/engine/requirements.txt @@ -1,4 +1,4 @@ -django==3.2.13 +django==3.2.14 djangorestframework==3.12.4 slackclient==1.3.0 whitenoise==5.3.0