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