From d8a32061302f4ee59cdd015ef074f9641abf3899 Mon Sep 17 00:00:00 2001 From: Julia Date: Tue, 12 Jul 2022 13:59:17 +0300 Subject: [PATCH] Add oncall shift endpoint to internal api --- engine/apps/api/serializers/on_call_shifts.py | 199 ++++++++++++++++++ engine/apps/api/urls.py | 2 + engine/apps/api/views/on_call_shifts.py | 100 +++++++++ .../public_api/serializers/on_call_shifts.py | 12 +- engine/common/api_helpers/custom_fields.py | 6 + 5 files changed, 312 insertions(+), 7 deletions(-) create mode 100644 engine/apps/api/serializers/on_call_shifts.py create mode 100644 engine/apps/api/views/on_call_shifts.py diff --git a/engine/apps/api/serializers/on_call_shifts.py b/engine/apps/api/serializers/on_call_shifts.py new file mode 100644 index 00000000..8d789a73 --- /dev/null +++ b/engine/apps/api/serializers/on_call_shifts.py @@ -0,0 +1,199 @@ +from rest_framework import serializers + +from apps.schedules.models import CustomOnCallShift, 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=OnCallScheduleWeb.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) + rolling_users = RollingUsersField( + allow_null=True, + required=False, + child=UsersFilteredByOrganizationField( + queryset=User.objects, required=False, allow_null=True + ), # todo: filter by team? + ) + + class Meta: + model = CustomOnCallShift + fields = [ + "id", + "organization", + "name", + "type", + "schedule", + "priority_level", + "shift_start", + "shift_end", + "rotation_start", + "until", + "frequency", + "interval", + "until", + "by_day", + "source", + "rolling_users", + ] + extra_kwargs = { + "interval": {"required": False, "allow_null": True}, + "source": {"required": False, "write_only": True}, + } + + SELECT_RELATED = ["schedule"] + + def get_shift_end(self, obj): + return obj.start + obj.duration + + def to_internal_value(self, data): + data["source"] = CustomOnCallShift.SOURCE_WEB + data["week_start"] = CustomOnCallShift.MONDAY + if not data.get("shift_end"): + raise serializers.ValidationError({"shift_end": ["This field is required."]}) + + result = super().to_internal_value(data) + return result + + def to_representation(self, instance): + result = super().to_representation(instance) + return result + + def validate_name(self, name): # todo + organization = self.context["request"].auth.organization + if name is None: + return name + try: + obj = CustomOnCallShift.objects.get(organization=organization, name=name) + except CustomOnCallShift.DoesNotExist: + return name + if self.instance and obj.id == self.instance.id: + return name + else: + raise serializers.ValidationError(["On-call shift with this name already exists"]) + + def validate_by_day(self, by_day): + if by_day: + for day in by_day: + if day not in CustomOnCallShift.ICAL_WEEKDAY_MAP.keys(): + raise serializers.ValidationError(["Invalid day value."]) + return by_day + + 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[user.pk] = user.public_primary_key + result.append(users_dict) + return result + + def _validate_shift_end(self, start, end): + if end <= start: + raise serializers.ValidationError({"shift_end": ["Incorrect shift end date"]}) + + def _validate_frequency(self, frequency, event_type, rolling_users, interval, by_day): + 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"]}) + else: + if event_type == CustomOnCallShift.TYPE_OVERRIDE: + raise serializers.ValidationError( + {"frequency": ["Cannot set 'frequency' for shifts with type 'override'"]} + ) + if frequency != CustomOnCallShift.FREQUENCY_WEEKLY and by_day: + raise serializers.ValidationError({"by_day": ["Cannot set days value for this frequency type"]}) + + def _validate_rotations_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", + "frequency", + "interval", + "by_day", + "until", + ] + if event_type == CustomOnCallShift.TYPE_OVERRIDE: + for field in fields_to_update_for_overrides: + value = None + if field == "priority_level": + value = 0 + 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"), + ) + self._validate_rotations_start(validated_data["start"], validated_data["rotation_start"]) + self._validate_until(validated_data["rotation_start"], validated_data["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) + + validated_data["duration"] = shift_end - validated_data["start"] + + return validated_data + + def create(self, validated_data): + validated_data = self._correct_validated_data(validated_data["type"], validated_data) + + instance = super().create(validated_data) + + instance.start_drop_ical_and_check_schedule_tasks(instance.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): + validated_data = self._correct_validated_data(instance.type, validated_data) + + result = super().update(instance, validated_data) + + instance.start_drop_ical_and_check_schedule_tasks(instance.schedule) + return result diff --git a/engine/apps/api/urls.py b/engine/apps/api/urls.py index a82ee4e9..1eb9adf2 100644 --- a/engine/apps/api/urls.py +++ b/engine/apps/api/urls.py @@ -17,6 +17,7 @@ from .views.gitops import TerraformGitOpsView, TerraformStateView from .views.integration_heartbeat import IntegrationHeartBeatView from .views.live_setting import LiveSettingViewSet from .views.maintenance import MaintenanceAPIView, MaintenanceStartAPIView, MaintenanceStopAPIView +from .views.on_call_shifts import OnCallShiftView from .views.organization import ( CurrentOrganizationView, GetChannelVerificationCode, @@ -65,6 +66,7 @@ router.register(r"heartbeats", IntegrationHeartBeatView, basename="integration_h router.register(r"organization_logs", OrganizationLogRecordView, basename="organization_log") router.register(r"tokens", PublicApiTokenView, basename="api_token") router.register(r"live_settings", LiveSettingViewSet, basename="live_settings") +router.register(r"oncall_shifts", OnCallShiftView, basename="oncall_shifts") if settings.MOBILE_APP_PUSH_NOTIFICATIONS_ENABLED: router.register(r"device/apns", APNSDeviceAuthorizedViewSet) diff --git a/engine/apps/api/views/on_call_shifts.py b/engine/apps/api/views/on_call_shifts.py new file mode 100644 index 00000000..b4c93449 --- /dev/null +++ b/engine/apps/api/views/on_call_shifts.py @@ -0,0 +1,100 @@ +from django.db.models import Q +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet + +from apps.api.permissions import MODIFY_ACTIONS, READ_ACTIONS, ActionPermission, AnyRole, IsAdmin +from apps.api.serializers.on_call_shifts import OnCallShiftSerializer, OnCallShiftUpdateSerializer +from apps.auth_token.auth import PluginAuthentication +from apps.schedules.models import CustomOnCallShift +from apps.user_management.organization_log_creator import OrganizationLogType, create_organization_log +from common.api_helpers.mixins import PublicPrimaryKeyMixin, UpdateSerializerMixin +from common.api_helpers.paginators import FiftyPageSizePaginator + + +class OnCallShiftView(PublicPrimaryKeyMixin, UpdateSerializerMixin, ModelViewSet): + authentication_classes = (PluginAuthentication,) + permission_classes = (IsAuthenticated, ActionPermission) + + action_permissions = { + IsAdmin: MODIFY_ACTIONS, + AnyRole: (*READ_ACTIONS, "details", "frequency_options", "days_options"), + } + + model = CustomOnCallShift + serializer_class = OnCallShiftSerializer + update_serializer_class = OnCallShiftUpdateSerializer + + pagination_class = FiftyPageSizePaginator + + filter_backends = [DjangoFilterBackend] + + def get_queryset(self): + schedule_id = self.request.query_params.get("schedule_id", None) + lookup_kwargs = {} + if schedule_id: + lookup_kwargs.update({"schedules__public_primary_key": schedule_id}) + + queryset = CustomOnCallShift.objects.filter( + Q(**lookup_kwargs), + organization=self.request.auth.organization, + team=self.request.user.current_team, + ) + + queryset = self.serializer_class.setup_eager_loading(queryset) + return queryset.order_by("schedules") + + def perform_create(self, serializer): + serializer.save() + instance = serializer.instance + organization = self.request.auth.organization + user = self.request.user + description = ( + f"Custom on-call shift with params: {instance.repr_settings_for_client_side_logging} " + f"was created" # todo + ) + create_organization_log(organization, user, OrganizationLogType.TYPE_ON_CALL_SHIFT_CREATED, description) + + def perform_update(self, serializer): + organization = self.request.auth.organization + user = self.request.user + old_state = serializer.instance.repr_settings_for_client_side_logging + serializer.save() + new_state = serializer.instance.repr_settings_for_client_side_logging + description = f"Settings of custom on-call shift was changed " f"from:\n{old_state}\nto:\n{new_state}" + create_organization_log(organization, user, OrganizationLogType.TYPE_ON_CALL_SHIFT_CHANGED, description) + + def perform_destroy(self, instance): + organization = self.request.auth.organization + user = self.request.user + description = ( + f"Custom on-call shift " f"with params: {instance.repr_settings_for_client_side_logging} was deleted" + ) + create_organization_log(organization, user, OrganizationLogType.TYPE_ON_CALL_SHIFT_DELETED, description) + instance.delete() + + @action(detail=False, methods=["get"]) + def frequency_options(self, request): + return Response( + [ + { + "display_name": display_name, + "value": freq, + } + for freq, display_name in CustomOnCallShift.WEB_FREQUENCY_CHOICES_MAP.items() + ] + ) + + @action(detail=False, methods=["get"]) + def days_options(self, request): + return Response( + [ + { + "display_name": display_name, + "value": day_number, + } + for day_number, display_name in CustomOnCallShift.WEEKDAY_CHOICES + ] + ) diff --git a/engine/apps/public_api/serializers/on_call_shifts.py b/engine/apps/public_api/serializers/on_call_shifts.py index 1fd85aa6..9b87f7f7 100644 --- a/engine/apps/public_api/serializers/on_call_shifts.py +++ b/engine/apps/public_api/serializers/on_call_shifts.py @@ -9,18 +9,16 @@ from apps.schedules.tasks import ( schedule_notify_about_gaps_in_schedule, ) from apps.user_management.models import User -from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField, UsersFilteredByOrganizationField +from common.api_helpers.custom_fields import ( + RollingUsersField, + TeamPrimaryKeyRelatedField, + UsersFilteredByOrganizationField, +) from common.api_helpers.exceptions import BadRequest from common.api_helpers.mixins import EagerLoadingMixin from common.api_helpers.utils import CurrentOrganizationDefault -class RollingUsersField(serializers.ListField): - def to_representation(self, value): - result = [list(d.values()) for d in value] - return result - - class CustomOnCallShiftTypeField(fields.CharField): def to_representation(self, value): return CustomOnCallShift.PUBLIC_TYPE_CHOICES_MAP[value] diff --git a/engine/common/api_helpers/custom_fields.py b/engine/common/api_helpers/custom_fields.py index 597aff52..79b96012 100644 --- a/engine/common/api_helpers/custom_fields.py +++ b/engine/common/api_helpers/custom_fields.py @@ -171,3 +171,9 @@ class UserIdField(fields.CharField): if value is not None: return value.public_primary_key return value + + +class RollingUsersField(serializers.ListField): + def to_representation(self, value): + result = [list(d.values()) for d in value] + return result