Merge pull request #205 from grafana/dev

Main to dev
This commit is contained in:
Matvey Kukuy 2022-07-06 10:11:51 +03:00 committed by GitHub
commit 56b4e12c69
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 127 additions and 22 deletions

View file

@ -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"

View file

@ -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)

View file

@ -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",

View file

@ -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):

View file

@ -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).

View file

@ -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),
),
]

View file

@ -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),
),
]

View file

@ -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

View file

@ -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

View file

@ -1,4 +1,4 @@
django==3.2.13
django==3.2.14
djangorestframework==3.12.4
slackclient==1.3.0
whitenoise==5.3.0