commit
56b4e12c69
10 changed files with 127 additions and 22 deletions
|
|
@ -53,8 +53,9 @@ 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. |
|
||||
| `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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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",
|
||||
|
|
@ -144,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
|
||||
|
||||
|
|
@ -190,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
|
||||
|
||||
|
|
@ -247,7 +252,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:
|
||||
|
|
@ -292,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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
django==3.2.13
|
||||
django==3.2.14
|
||||
djangorestframework==3.12.4
|
||||
slackclient==1.3.0
|
||||
whitenoise==5.3.0
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue