From c8b8449afc81dfd45cb4b5bbc3507eea09efb7f2 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Tue, 28 Jun 2022 11:21:36 -0300 Subject: [PATCH] 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