Merge pull request #196 from grafana/matiasb-web-schedule-backend-1

Initial web schedule model and serializers. Add override shift type.
This commit is contained in:
Matias Bordese 2022-07-08 14:27:01 -03:00 committed by GitHub
commit b5e624e2ed
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 976 additions and 95 deletions

View file

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

View file

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

View file

@ -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):
@ -326,6 +429,152 @@ def test_events_calendar(
"calendar_type": OnCallSchedule.PRIMARY,
"is_empty": False,
"is_gap": False,
"shift": {
"pk": on_call_shift.public_primary_key,
},
}
],
}
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": {
"pk": on_call_shift.public_primary_key,
},
},
{
"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": {
"pk": on_call_shift.public_primary_key,
},
},
],
}
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": {
"pk": on_call_shift.public_primary_key,
},
}
],
}

View file

@ -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,22 @@ class ScheduleView(
"calendar_type": shift["calendar_type"],
"is_empty": len(shift["users"]) == 0 and not is_gap,
"is_gap": is_gap,
"shift": {
"pk": shift["shift_pk"],
},
}
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 +249,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)

View file

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

View file

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

View file

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

View file

@ -0,0 +1,95 @@
import pytz
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:
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)

View file

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

View file

@ -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,96 @@ 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}")
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
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}")
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
def test_update_ical_url_overrides_calendar_schedule(
make_organization_and_user_with_token,
@ -237,6 +333,68 @@ 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}")
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
def test_delete_calendar_schedule(
make_organization_and_user_with_token,

View file

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

View file

@ -75,12 +75,16 @@ ICAL_DESCRIPTION = "DESCRIPTION"
ICAL_ATTENDEE = "ATTENDEE"
ICAL_UID = "UID"
RE_PRIORITY = re.compile(r"^\[L(\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)
# 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 +110,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 +141,7 @@ def list_of_oncall_shifts_from_ical(schedule, date, user_timezone="UTC", with_em
"source": None,
"calendar_type": None,
"is_gap": True,
"shift_pk": None,
}
)
result = sorted(result_datetime, key=lambda dt: dt["start"]) + result_date
@ -150,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]"))
source = parse_source_from_string(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:
@ -166,6 +171,7 @@ def get_shifts_dict(calendar, calendar_type, schedule, datetime_start, datetime_
"priority": priority,
"source": source,
"calendar_type": calendar_type,
"shift_pk": pk,
}
)
else:
@ -180,13 +186,15 @@ def get_shifts_dict(calendar, calendar_type, schedule, datetime_start, datetime_
"priority": priority,
"source": source,
"calendar_type": calendar_type,
"shift_pk": pk,
}
)
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_pk"],
)
@ -230,6 +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, "")
pk, _ = 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 +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_pk=pk,
)
)
empty_shifts.extend(empty_shifts_per_calendar)
@ -330,17 +340,27 @@ 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):
pk = None
source = 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_V2.match(string)
if match:
_, 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 pk, source_verbal
def get_usernames_from_ical_event(event):

View file

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

View file

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

View file

@ -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}, "
@ -220,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))

View file

@ -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
@ -212,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):
@ -254,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:
@ -350,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)
@ -394,3 +359,58 @@ 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"])
@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"])

View file

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

View file

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

View file

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

View file

@ -6,6 +6,7 @@ import { UserGroup } from 'models/user_group/user_group.types';
export enum ScheduleType {
'Calendar',
'Ical',
'Web',
}
export interface Schedule {

View file

@ -350,7 +350,7 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
type tTypeToVerbal = {
[key: number]: string;
};
const typeToVerbal: tTypeToVerbal = { 0: 'API/Terraform', 1: 'Ical' };
const typeToVerbal: tTypeToVerbal = { 0: 'API/Terraform', 1: 'Ical', 2: 'Web' };
return typeToVerbal[value];
};