oncall-engine/engine/apps/api/serializers/on_call_shifts.py
Ravishankar 1f209cd2bd
fix: Add rolling users validation for oncall shift API (#5050)
# What this PR does
Adds validation for rolling users param in the shift api

## Which issue(s) this PR closes
Closes [5041](https://github.com/grafana/oncall/issues/5041)

<!--
*Note*: If you want the issue to be auto-closed once the PR is merged,
change "Related to" to "Closes" in the line above.
If you have more than one GitHub issue that this PR closes, be sure to
preface
each issue link with a [closing
keyword](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/using-keywords-in-issues-and-pull-requests#linking-a-pull-request-to-an-issue).
This ensures that the issue(s) are auto-closed once the PR has been
merged.
-->

## Checklist

- [x] Unit, integration, and e2e (if applicable) tests updated
- [x] Documentation added (or `pr:no public docs` PR label added if not
required)
- [x] Added the relevant release notes label (see labels prefixed w/
`release:`). These labels dictate how your PR will
    show up in the autogenerated release notes.
2024-09-20 21:06:33 +00:00

264 lines
11 KiB
Python

from django.utils import timezone
from rest_framework import serializers
from apps.schedules.models import CustomOnCallShift, OnCallSchedule, OnCallScheduleWeb
from apps.user_management.models import User
from common.api_helpers.custom_fields import (
OrganizationFilteredPrimaryKeyRelatedField,
RollingUsersField,
UsersFilteredByOrganizationField,
)
from common.api_helpers.mixins import EagerLoadingMixin
from common.api_helpers.utils import CurrentOrganizationDefault
class OnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer):
id = serializers.CharField(read_only=True, source="public_primary_key")
organization = serializers.HiddenField(default=CurrentOrganizationDefault())
type = serializers.ChoiceField(
required=True,
choices=CustomOnCallShift.WEB_TYPES,
)
schedule = OrganizationFilteredPrimaryKeyRelatedField(queryset=OnCallSchedule.objects)
frequency = serializers.ChoiceField(required=False, choices=CustomOnCallShift.FREQUENCY_CHOICES, allow_null=True)
shift_start = serializers.DateTimeField(source="start")
shift_end = serializers.SerializerMethodField()
by_day = serializers.ListField(required=False, allow_null=True)
week_start = serializers.CharField(required=False, allow_null=True)
rolling_users = RollingUsersField(
allow_null=True,
required=False,
child=UsersFilteredByOrganizationField(
queryset=User.objects, require_all_exist=True, required=False, allow_null=True
), # todo: filter by team?
)
updated_shift = serializers.CharField(read_only=True, allow_null=True, source="updated_shift.public_primary_key")
# Name is optional to keep backward compatibility with older frontends
name = serializers.CharField(required=False)
class Meta:
model = CustomOnCallShift
fields = [
"id",
"organization",
"name",
"type",
"schedule",
"priority_level",
"shift_start",
"shift_end",
"rotation_start",
"until",
"frequency",
"interval",
"by_day",
"week_start",
"source",
"rolling_users",
"updated_shift",
"start_rotation_from_user_index",
]
extra_kwargs = {
"interval": {"required": False, "allow_null": True},
"source": {"required": False, "write_only": True},
}
SELECT_RELATED = ["schedule", "updated_shift"]
PREFETCH_RELATED = ["schedules"]
def get_shift_end(self, obj):
return obj.start + obj.duration
def to_representation(self, instance):
ret = super().to_representation(instance)
ret["week_start"] = CustomOnCallShift.ICAL_WEEKDAY_MAP[instance.week_start]
if ret["schedule"] is None:
# for terraform based schedules, related schedule comes from M2M field
# TODO: migrate terraform schedules to use FK instead
related_schedules = instance.schedules.all()
ret["schedule"] = related_schedules[0].public_primary_key if related_schedules else None
return ret
def to_internal_value(self, data):
data["source"] = CustomOnCallShift.SOURCE_WEB
if not data.get("shift_end"):
raise serializers.ValidationError({"shift_end": ["This field is required."]})
result = super().to_internal_value(data)
return result
def validate_by_day(self, by_day):
if by_day:
for day in by_day:
if day not in CustomOnCallShift.WEB_WEEKDAY_MAP:
raise serializers.ValidationError(["Invalid day value."])
return by_day
def _validate_type(self, schedule, event_type):
if schedule and not isinstance(schedule, OnCallScheduleWeb) and event_type != CustomOnCallShift.TYPE_OVERRIDE:
# if this is not related to a web schedule, only allow override web events
raise serializers.ValidationError({"type": ["Invalid event type"]})
def validate_week_start(self, week_start):
if week_start is None:
week_start = CustomOnCallShift.MONDAY
if week_start not in CustomOnCallShift.WEB_WEEKDAY_MAP:
raise serializers.ValidationError(["Invalid week start value."])
return CustomOnCallShift.ICAL_WEEKDAY_REVERSE_MAP[week_start]
def validate_interval(self, interval):
if interval is not None:
if not isinstance(interval, int) or interval <= 0:
raise serializers.ValidationError(["Invalid value"])
return interval
def validate_rolling_users(self, rolling_users):
result = []
if rolling_users:
for users in rolling_users:
users_dict = dict()
for user in users:
users_dict[str(user.pk)] = user.public_primary_key
result.append(users_dict)
return result
def _validate_shift_end(self, start, end, event_type):
if end <= start:
raise serializers.ValidationError({"shift_end": ["Incorrect shift end date"]})
if event_type == CustomOnCallShift.TYPE_OVERRIDE and timezone.now() > end:
raise serializers.ValidationError({"shift_end": ["Cannot create or update an override in the past"]})
def _validate_frequency(self, frequency, event_type, rolling_users, interval, by_day, until):
if frequency is None:
if rolling_users and len(rolling_users) > 1:
raise serializers.ValidationError(
{"rolling_users": ["Cannot set multiple user groups for non-recurrent shifts"]}
)
if interval is not None:
raise serializers.ValidationError({"interval": ["Cannot set interval for non-recurrent shifts"]})
if by_day:
raise serializers.ValidationError({"by_day": ["Cannot set days value for non-recurrent shifts"]})
if until:
raise serializers.ValidationError({"until": ["Cannot set 'until' for non-recurrent shifts"]})
else:
if event_type == CustomOnCallShift.TYPE_OVERRIDE:
raise serializers.ValidationError(
{"frequency": ["Cannot set 'frequency' for shifts with type 'override'"]}
)
if interval is None:
raise serializers.ValidationError(
{"interval": ["If frequency is set, interval must be a positive integer"]}
)
if frequency == CustomOnCallShift.FREQUENCY_DAILY and by_day and interval > len(by_day):
raise serializers.ValidationError(
{"interval": ["Interval must be less than or equal to the number of selected days"]}
)
def _validate_rotation_start(self, shift_start, rotation_start):
if rotation_start < shift_start:
raise serializers.ValidationError({"rotation_start": ["Incorrect rotation start date"]})
def _validate_until(self, rotation_start, until):
if until is not None and until < rotation_start:
raise serializers.ValidationError({"until": ["Incorrect rotation end date"]})
def _correct_validated_data(self, event_type, validated_data):
fields_to_update_for_overrides = [
"priority_level",
"rotation_start",
]
self._validate_type(validated_data.get("schedule"), event_type)
if event_type == CustomOnCallShift.TYPE_OVERRIDE:
for field in fields_to_update_for_overrides:
value = None
if field == "priority_level":
value = 99
elif field == "rotation_start":
value = validated_data["start"]
validated_data[field] = value
self._validate_frequency(
validated_data.get("frequency"),
event_type,
validated_data.get("rolling_users"),
validated_data.get("interval"),
validated_data.get("by_day"),
validated_data.get("until"),
)
self._validate_rotation_start(validated_data["start"], validated_data["rotation_start"])
self._validate_until(validated_data["rotation_start"], validated_data.get("until"))
# convert shift_end into internal value and validate
raw_shift_end = self.initial_data["shift_end"]
shift_end = serializers.DateTimeField().to_internal_value(raw_shift_end)
self._validate_shift_end(validated_data["start"], shift_end, event_type)
validated_data["duration"] = shift_end - validated_data["start"]
if validated_data.get("schedule"):
validated_data["team"] = validated_data["schedule"].team
validated_data["week_start"] = validated_data.get("week_start", CustomOnCallShift.MONDAY)
return validated_data
def _require_users(self, validated_data):
users = validated_data.get("rolling_users")
if not users:
raise serializers.ValidationError({"rolling_users": ["User(s) are required"]})
def create(self, validated_data):
validated_data = self._correct_validated_data(validated_data["type"], validated_data)
# before creation, require users set
self._require_users(validated_data)
instance = super().create(validated_data)
# refresh related schedule ical files
instance.refresh_schedule()
return instance
class OnCallShiftUpdateSerializer(OnCallShiftSerializer):
schedule = serializers.CharField(read_only=True, source="schedule.public_primary_key")
type = serializers.ReadOnlyField()
class Meta(OnCallShiftSerializer.Meta):
read_only_fields = ["schedule", "type"]
def update(self, instance, validated_data):
if not instance.schedule:
# only web-based schedule events can be updated using UI
raise serializers.ValidationError(["This event cannot be updated"])
validated_data = self._correct_validated_data(instance.type, validated_data)
change_only_name = True
create_or_update_last_shift = False
force_update = validated_data.pop("force_update", True)
for field in validated_data:
if field != "name" and validated_data[field] != getattr(instance, field):
change_only_name = False
break
if not change_only_name:
if instance.type != CustomOnCallShift.TYPE_OVERRIDE:
if instance.event_is_started:
create_or_update_last_shift = True
elif instance.event_is_finished:
raise serializers.ValidationError(["This event cannot be updated"])
# before update, require users set
self._require_users(validated_data)
if not force_update and create_or_update_last_shift:
result = instance.create_or_update_last_shift(validated_data)
else:
result = super().update(instance, validated_data)
# refresh related schedule ical files
instance.refresh_schedule()
return result