import logging import typing import pytz from django.conf import settings from django.core.exceptions import ObjectDoesNotExist from django.db.utils import IntegrityError from django.urls import reverse from django.utils import timezone from django.utils.functional import cached_property from django_filters import rest_framework as filters from drf_spectacular.plumbing import resolve_type_hint from drf_spectacular.utils import PolymorphicProxySerializer, extend_schema, inline_serializer from rest_framework import mixins, serializers, status, viewsets from rest_framework.decorators import action from rest_framework.exceptions import NotFound from rest_framework.filters import SearchFilter from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from apps.api.permissions import ( ALL_PERMISSION_CHOICES, ALL_PERMISSION_NAME_TO_CLASS_MAP, IsOwnerOrHasRBACPermissions, LegacyAccessControlRole, RBACPermission, user_is_authorized, ) from apps.api.serializers.team import TeamSerializer from apps.api.serializers.user import ( CurrentUserSerializer, FilterUserSerializer, ListUserSerializer, UserHiddenFieldsSerializer, UserIsCurrentlyOnCallSerializer, UserSerializer, ) from apps.api.throttlers import ( GetPhoneVerificationCodeThrottlerPerOrg, GetPhoneVerificationCodeThrottlerPerUser, TestCallThrottler, VerifyPhoneNumberThrottlerPerOrg, VerifyPhoneNumberThrottlerPerUser, ) from apps.api.throttlers.test_call_throttler import TestPushThrottler from apps.auth_token.auth import PluginAuthentication from apps.auth_token.constants import SCHEDULE_EXPORT_TOKEN_NAME from apps.auth_token.models import UserScheduleExportAuthToken from apps.base.messaging import get_messaging_backend_from_id from apps.base.utils import live_settings from apps.mobile_app.auth import MobileAppAuthTokenAuthentication from apps.mobile_app.demo_push import send_test_push from apps.mobile_app.exceptions import DeviceNotSet from apps.phone_notifications.exceptions import ( BaseFailed, FailedToFinishVerification, FailedToMakeCall, FailedToStartVerification, NumberAlreadyVerified, NumberNotVerified, PhoneNumberBanned, ProviderNotSupports, ) from apps.phone_notifications.phone_backend import PhoneBackend from apps.schedules.ical_utils import get_cached_oncall_users_for_multiple_schedules from apps.schedules.models import OnCallSchedule from apps.schedules.models.on_call_schedule import ScheduleEvent from apps.telegram.client import TelegramClient from apps.telegram.models import TelegramVerificationCode from apps.user_management.models import Team, User from common.api_helpers.exceptions import Conflict from common.api_helpers.filters import ByTeamModelFieldFilterMixin, TeamModelMultipleChoiceFilter from common.api_helpers.mixins import PublicPrimaryKeyMixin from common.api_helpers.paginators import HundredPageSizePaginator from common.api_helpers.utils import create_engine_url from common.insight_log import ( ChatOpsEvent, ChatOpsTypePlug, EntityEvent, write_chatops_insight_log, write_resource_insight_log, ) from common.recaptcha import check_recaptcha_internal_api logger = logging.getLogger(__name__) IsOwnerOrHasUserSettingsAdminPermission = IsOwnerOrHasRBACPermissions([RBACPermission.Permissions.USER_SETTINGS_ADMIN]) IsOwnerOrHasUserSettingsReadPermission = IsOwnerOrHasRBACPermissions([RBACPermission.Permissions.USER_SETTINGS_READ]) UPCOMING_SHIFTS_DEFAULT_DAYS = 7 UPCOMING_SHIFTS_MAX_DAYS = 65 class UpcomingShift(typing.TypedDict): schedule_id: str schedule_name: str is_oncall: bool current_shift: ScheduleEvent | None next_shift: ScheduleEvent | None UpcomingShifts = list[UpcomingShift] class CachedSchedulesContextMixin: @cached_property def schedules_with_oncall_users(self): """ The result of this method is cached and is reused for the whole lifetime of a request, since self.get_serializer_context() is called multiple times for every instance in the queryset. """ return get_cached_oncall_users_for_multiple_schedules(self.request.user.organization.oncall_schedules.all()) def _populate_schedules_oncall_cache(self): return False def get_serializer_context(self): context = getattr(super(), "get_serializer_context", lambda: {})() context.update( { "schedules_with_oncall_users": self.schedules_with_oncall_users if self._populate_schedules_oncall_cache() else {} } ) return context @extend_schema(responses={status.HTTP_200_OK: CurrentUserSerializer}) class CurrentUserView(APIView, CachedSchedulesContextMixin): authentication_classes = (MobileAppAuthTokenAuthentication, PluginAuthentication) permission_classes = (IsAuthenticated,) def _populate_schedules_oncall_cache(self): return True def get(self, request): context = self.get_serializer_context() context.update({"request": self.request, "format": self.format_kwarg, "view": self}) is_open_source_with_cloud_notifications = ( settings.IS_OPEN_SOURCE and live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED ) # set context to avoid additional requests to db context["is_open_source_with_cloud_notifications"] = is_open_source_with_cloud_notifications if is_open_source_with_cloud_notifications: from apps.oss_installation.models import CloudConnector, CloudUserIdentity connector = CloudConnector.objects.first() if connector is not None: cloud_identities = list(CloudUserIdentity.objects.filter(email__in=[request.user.email])) cloud_identities = {cloud_identity.email: cloud_identity for cloud_identity in cloud_identities} context["cloud_identities"] = cloud_identities context["connector"] = connector serializer = CurrentUserSerializer(request.user, context=context) return Response(serializer.data) def put(self, request): context = self.get_serializer_context() context.update({"request": self.request}) data = self.request.data serializer = CurrentUserSerializer(request.user, data=data, context=context) serializer.is_valid(raise_exception=True) serializer.save() return Response(serializer.data) class UserFilter(ByTeamModelFieldFilterMixin, filters.FilterSet): """ https://django-filter.readthedocs.io/en/master/guide/rest_framework.html """ email = filters.CharFilter(field_name="email", lookup_expr="icontains") # TODO: remove "roles" in next version roles = filters.MultipleChoiceFilter(field_name="role", choices=LegacyAccessControlRole.choices()) permission = filters.ChoiceFilter(method="filter_by_permission", choices=ALL_PERMISSION_CHOICES) team = TeamModelMultipleChoiceFilter(field_name="teams") class Meta: model = User # TODO: remove "roles" in next version fields = ["email", "roles", "permission"] def filter_by_permission(self, queryset, name, value): rbac_permission = ALL_PERMISSION_NAME_TO_CLASS_MAP.get(value, None) if not rbac_permission: # TODO: maybe raise a 400 here? return queryset return queryset.filter_by_permission(rbac_permission, self.request.user.organization) class UserView( PublicPrimaryKeyMixin[User], CachedSchedulesContextMixin, mixins.RetrieveModelMixin, mixins.UpdateModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet, ): """ Internal API endpoints for users. """ authentication_classes = ( MobileAppAuthTokenAuthentication, PluginAuthentication, ) permission_classes = (IsAuthenticated, RBACPermission) rbac_permissions = { "retrieve": [RBACPermission.Permissions.USER_SETTINGS_READ], "timezone_options": [RBACPermission.Permissions.USER_SETTINGS_READ], "check_availability": [RBACPermission.Permissions.USER_SETTINGS_READ], "metadata": [RBACPermission.Permissions.USER_SETTINGS_WRITE], "list": [RBACPermission.Permissions.USER_SETTINGS_READ], "update": [RBACPermission.Permissions.USER_SETTINGS_WRITE], "partial_update": [RBACPermission.Permissions.USER_SETTINGS_WRITE], "verify_number": [RBACPermission.Permissions.USER_SETTINGS_WRITE], "forget_number": [RBACPermission.Permissions.USER_SETTINGS_WRITE], "get_verification_code": [RBACPermission.Permissions.USER_SETTINGS_WRITE], "get_verification_call": [RBACPermission.Permissions.USER_SETTINGS_WRITE], "get_backend_verification_code": [RBACPermission.Permissions.USER_SETTINGS_READ], "get_telegram_verification_code": [RBACPermission.Permissions.USER_SETTINGS_WRITE], "unlink_slack": [RBACPermission.Permissions.USER_SETTINGS_WRITE], "unlink_telegram": [RBACPermission.Permissions.USER_SETTINGS_WRITE], "unlink_backend": [RBACPermission.Permissions.USER_SETTINGS_READ], "make_test_call": [RBACPermission.Permissions.USER_SETTINGS_WRITE], "send_test_push": [RBACPermission.Permissions.USER_SETTINGS_READ], "send_test_sms": [RBACPermission.Permissions.USER_SETTINGS_WRITE], "export_token": [RBACPermission.Permissions.USER_SETTINGS_WRITE], "upcoming_shifts": [RBACPermission.Permissions.USER_SETTINGS_READ], "filters": [RBACPermission.Permissions.USER_SETTINGS_READ], } rbac_object_permissions = { IsOwnerOrHasUserSettingsAdminPermission: [ "metadata", "list", "update", "partial_update", "destroy", "verify_number", "forget_number", "get_verification_code", "get_verification_call", "get_backend_verification_code", "get_telegram_verification_code", "unlink_slack", "unlink_telegram", "unlink_backend", "make_test_call", "send_test_sms", "send_test_push", "export_token", "upcoming_shifts", ], IsOwnerOrHasUserSettingsReadPermission: [ "check_availability", "retrieve", ], } queryset = User.objects.none() # needed for drf-spectacular introspection pagination_class = HundredPageSizePaginator filter_backends = (SearchFilter, filters.DjangoFilterBackend) # NB start search params # '^' Starts-with search. # '=' Exact matches. # '@' Full-text search. (Currently only supported Django's MySQL backend.) # '$' Regex search. search_fields = ( "^email", "^username", "^slack_user_identity__cached_slack_login", "^slack_user_identity__cached_name", "^teams__name", "=public_primary_key", ) filterset_class = UserFilter def _get_is_currently_oncall_query_param(self) -> str: return self.request.query_params.get("is_currently_oncall", "").lower() def _populate_schedules_oncall_cache(self): return ( # admin or owner can see on-call schedule information for a user (self.is_owner_or_admin() and self.action != "list") or # list requests need to explicitly request on-call information self._get_is_currently_oncall_query_param() in ["true", "false", "all"] ) def is_owner_or_admin(self): request = self.request user = request.user kwargs = self.kwargs is_users_own_data = kwargs.get("pk") is not None and kwargs.get("pk") == user.public_primary_key has_admin_permission = user_is_authorized(user, [RBACPermission.Permissions.USER_SETTINGS_ADMIN]) return is_users_own_data or has_admin_permission def get_serializer_class(self): request = self.request query_params = request.query_params is_list_request = self.action == "list" is_filters_request = query_params.get("filters", "false") == "true" is_owner_or_admin = self.is_owner_or_admin() # default serializer serializer = UserHiddenFieldsSerializer # list requests if is_list_request: if is_owner_or_admin: serializer = ListUserSerializer if is_filters_request: serializer = FilterUserSerializer elif self._populate_schedules_oncall_cache(): serializer = UserIsCurrentlyOnCallSerializer return serializer # non-list requests if is_owner_or_admin: serializer = UserSerializer return serializer def get_queryset(self): slack_identity = self.request.query_params.get("slack_identity", None) == "true" queryset = User.objects.filter(organization=self.request.user.organization) queryset = self.get_serializer_class().setup_eager_loading(queryset) if slack_identity: queryset = queryset.filter(slack_user_identity__isnull=False).distinct() return queryset.order_by("id") @extend_schema( responses=PolymorphicProxySerializer( component_name="UserPolymorphic", serializers=[FilterUserSerializer, UserIsCurrentlyOnCallSerializer, ListUserSerializer], resource_type_field_name=None, ) ) def list(self, request, *args, **kwargs) -> Response: queryset = self.filter_queryset(self.get_queryset()) def _get_oncall_user_ids(): return {user.pk for _, users in self.schedules_with_oncall_users.items() for user in users} paginate_results = True if (is_currently_oncall_query_param := self._get_is_currently_oncall_query_param()) == "true": # client explicitly wants to filter out users that are on-call queryset = queryset.filter(pk__in=_get_oncall_user_ids()) elif is_currently_oncall_query_param == "false": # user explicitly wants to filter out on-call users queryset = queryset.exclude(pk__in=_get_oncall_user_ids()) elif is_currently_oncall_query_param == "all": # return all users, don't paginate paginate_results = False context = self.get_serializer_context() if paginate_results and (page := self.paginate_queryset(queryset)) is not None: is_open_source_with_cloud_notifications = ( settings.IS_OPEN_SOURCE and live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED ) # set context to avoid additional requests to db context["is_open_source_with_cloud_notifications"] = is_open_source_with_cloud_notifications if is_open_source_with_cloud_notifications: from apps.oss_installation.models import CloudConnector, CloudUserIdentity if (connector := CloudConnector.objects.first()) is not None: emails = list(queryset.values_list("email", flat=True)) cloud_identities = list(CloudUserIdentity.objects.filter(email__in=emails)) cloud_identities = {cloud_identity.email: cloud_identity for cloud_identity in cloud_identities} context["cloud_identities"] = cloud_identities context["connector"] = connector serializer = self.get_serializer(page, many=True, context=context) return self.get_paginated_response(serializer.data) serializer = self.get_serializer(queryset, many=True, context=context) return Response(serializer.data) @extend_schema(responses=UserSerializer) def retrieve(self, request, *args, **kwargs) -> Response: context = self.get_serializer_context() try: instance = self.get_object() except NotFound: return self.wrong_team_response() is_open_source_with_cloud_notifications = ( settings.IS_OPEN_SOURCE and live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED ) # set context to avoid additional requests to db context["is_open_source_with_cloud_notifications"] = is_open_source_with_cloud_notifications if is_open_source_with_cloud_notifications: from apps.oss_installation.models import CloudConnector, CloudUserIdentity connector = CloudConnector.objects.first() if connector is not None: cloud_identities = list(CloudUserIdentity.objects.filter(email__in=[instance.email])) cloud_identities = {cloud_identity.email: cloud_identity for cloud_identity in cloud_identities} context["cloud_identities"] = cloud_identities context["connector"] = connector serializer = self.get_serializer(instance, context=context) return Response(serializer.data) @extend_schema(request=UserSerializer, responses=UserSerializer) def update(self, request, *args, **kwargs): return super().update(request, *args, **kwargs) @extend_schema(request=UserSerializer, responses=UserSerializer) def partial_update(self, request, *args, **kwargs): return super().partial_update(request, *args, **kwargs) def wrong_team_response(self) -> Response: """ This method returns 403 and {"error_code": "wrong_team", "owner_team": {"name", "id", "email", "avatar_url"}}. Used in case if a requested instance doesn't belong to user's current_team. Used instead of TeamFilteringMixin because of m2m teams field (mixin doesn't work correctly with this) and overridden retrieve method in UserView. """ queryset = User.objects.filter(organization=self.request.user.organization).order_by("id") queryset = self.filter_queryset(queryset) try: queryset.get(public_primary_key=self.kwargs["pk"]) except ObjectDoesNotExist: raise NotFound general_team = Team(public_primary_key=None, name="General", email=None, avatar_url=None) return Response( data={"error_code": "wrong_team", "owner_team": TeamSerializer(general_team).data}, status=status.HTTP_403_FORBIDDEN, ) @extend_schema(responses={status.HTTP_200_OK: resolve_type_hint(typing.List[str])}) @action(detail=False, methods=["get"]) def timezone_options(self, request) -> Response: return Response(pytz.common_timezones) @action( detail=True, methods=["get"], throttle_classes=[GetPhoneVerificationCodeThrottlerPerUser, GetPhoneVerificationCodeThrottlerPerOrg], ) def get_verification_code(self, request, pk) -> Response: logger.info("get_verification_code: validating reCAPTCHA code") valid = check_recaptcha_internal_api(request, "mobile_verification_code") if not valid: logger.warning("get_verification_code: invalid reCAPTCHA validation") return Response("failed reCAPTCHA check", status=status.HTTP_400_BAD_REQUEST) logger.info('get_verification_code: pass reCAPTCHA validation"') user = self.get_object() phone_backend = PhoneBackend() try: phone_backend.send_verification_sms(user) except NumberAlreadyVerified: return Response("Phone number already verified", status=status.HTTP_400_BAD_REQUEST) except PhoneNumberBanned: return Response("Phone number has been banned", status=status.HTTP_403_FORBIDDEN) except FailedToStartVerification as e: return handle_phone_notificator_failed(e) except ProviderNotSupports: return Response( "Phone provider not supports sms verification", status=status.HTTP_500_INTERNAL_SERVER_ERROR ) return Response(status=status.HTTP_200_OK) @action( detail=True, methods=["get"], throttle_classes=[GetPhoneVerificationCodeThrottlerPerUser, GetPhoneVerificationCodeThrottlerPerOrg], ) def get_verification_call(self, request, pk) -> Response: logger.info("get_verification_code_via_call: validating reCAPTCHA code") valid = check_recaptcha_internal_api(request, "mobile_verification_code") if not valid: logger.warning("get_verification_code_via_call: invalid reCAPTCHA validation") return Response("failed reCAPTCHA check", status=status.HTTP_400_BAD_REQUEST) logger.info('get_verification_code_via_call: pass reCAPTCHA validation"') user = self.get_object() phone_backend = PhoneBackend() try: phone_backend.make_verification_call(user) except NumberAlreadyVerified: return Response("Phone number already verified", status=status.HTTP_400_BAD_REQUEST) except PhoneNumberBanned: return Response("Phone number has been banned", status=status.HTTP_403_FORBIDDEN) except FailedToStartVerification as e: return handle_phone_notificator_failed(e) except ProviderNotSupports: return Response( "Phone provider not supports call verification", status=status.HTTP_500_INTERNAL_SERVER_ERROR ) return Response(status=status.HTTP_200_OK) @extend_schema(parameters=[inline_serializer(name="UserVerifyNumber", fields={"token": serializers.CharField()})]) @action( detail=True, methods=["put"], throttle_classes=[VerifyPhoneNumberThrottlerPerUser, VerifyPhoneNumberThrottlerPerOrg], ) def verify_number(self, request, pk) -> Response: target_user = self.get_object() code = request.query_params.get("token", None) if not code: return Response("Invalid verification code", status=status.HTTP_400_BAD_REQUEST) prev_state = target_user.insight_logs_serialized phone_backend = PhoneBackend() try: verified = phone_backend.verify_phone_number(target_user, code) except FailedToFinishVerification as e: return handle_phone_notificator_failed(e) if verified: new_state = target_user.insight_logs_serialized write_resource_insight_log( instance=target_user, author=self.request.user, event=EntityEvent.UPDATED, prev_state=prev_state, new_state=new_state, ) return Response(status=status.HTTP_200_OK) else: return Response("Verification code is not correct", status=status.HTTP_400_BAD_REQUEST) @action(detail=True, methods=["put"]) def forget_number(self, request, pk) -> Response: target_user = self.get_object() prev_state = target_user.insight_logs_serialized phone_backend = PhoneBackend() removed = phone_backend.forget_number(target_user) if removed: new_state = target_user.insight_logs_serialized write_resource_insight_log( instance=target_user, author=self.request.user, event=EntityEvent.UPDATED, prev_state=prev_state, new_state=new_state, ) return Response(status=status.HTTP_200_OK) @action(detail=True, methods=["post"], throttle_classes=[TestCallThrottler]) def make_test_call(self, request, pk) -> Response: user = self.get_object() try: phone_backend = PhoneBackend() phone_backend.make_test_call(user) except NumberNotVerified: return Response("Phone number is not verified", status=status.HTTP_400_BAD_REQUEST) except FailedToMakeCall as e: return handle_phone_notificator_failed(e) except ProviderNotSupports: return Response("Phone provider not supports phone calls", status=status.HTTP_500_INTERNAL_SERVER_ERROR) return Response(status=status.HTTP_200_OK) @action(detail=True, methods=["post"], throttle_classes=[TestCallThrottler]) def send_test_sms(self, request, pk) -> Response: user = self.get_object() try: phone_backend = PhoneBackend() phone_backend.send_test_sms(user) except NumberNotVerified: return Response("Phone number is not verified", status=status.HTTP_400_BAD_REQUEST) except FailedToMakeCall as e: return handle_phone_notificator_failed(e) except ProviderNotSupports: return Response("Phone provider not supports phone calls", status=status.HTTP_500_INTERNAL_SERVER_ERROR) return Response(status=status.HTTP_200_OK) @extend_schema( parameters=[ inline_serializer( name="UserSendTestPush", fields={"critical": serializers.BooleanField(required=False, default=False)} ) ] ) @action(detail=True, methods=["post"], throttle_classes=[TestPushThrottler]) def send_test_push(self, request, pk) -> Response: user = self.get_object() critical = request.query_params.get("critical", "false") == "true" try: send_test_push(user, critical) except DeviceNotSet: return Response( data="Mobile device not connected", status=status.HTTP_400_BAD_REQUEST, ) except Exception as e: logger.info(f"UserView.send_test_push: Unable to send test push due to {e}") return Response( data="Something went wrong while sending a test push", status=status.HTTP_500_INTERNAL_SERVER_ERROR ) return Response(status=status.HTTP_200_OK) @extend_schema( parameters=[ inline_serializer(name="UserGetBackendVerificationCode", fields={"backend": serializers.CharField()}) ] ) @action(detail=True, methods=["get"]) def get_backend_verification_code(self, request, pk) -> Response: user = self.get_object() backend_id = request.query_params.get("backend") backend = get_messaging_backend_from_id(backend_id) if backend is None: return Response(status=status.HTTP_400_BAD_REQUEST) code = backend.generate_user_verification_code(user) return Response(code) @extend_schema( responses=inline_serializer( name="UserGetTelegramVerificationCode", fields={ "telegram_code": serializers.CharField(), "bot_link": serializers.CharField(), }, ) ) @action(detail=True, methods=["get"]) def get_telegram_verification_code(self, request, pk) -> Response: user = self.get_object() if user.is_telegram_connected: return Response("This user is already connected to a Telegram account", status=status.HTTP_400_BAD_REQUEST) try: existing_verification_code = user.telegram_verification_code existing_verification_code.delete() except TelegramVerificationCode.DoesNotExist: pass new_code = TelegramVerificationCode(user=user) new_code.save() telegram_client = TelegramClient() bot_username = telegram_client.api_client.username bot_link = f"https://t.me/{bot_username}" return Response( {"telegram_code": str(new_code.uuid_with_org_uuid), "bot_link": bot_link}, status=status.HTTP_200_OK ) @action(detail=True, methods=["post"]) def unlink_slack(self, request, pk) -> Response: user = self.get_object() user.slack_user_identity = None user.save(update_fields=["slack_user_identity"]) write_chatops_insight_log( author=request.user, event_name=ChatOpsEvent.USER_UNLINKED, chatops_type=ChatOpsTypePlug.SLACK.value, linked_user=user.username, linked_user_id=user.public_primary_key, ) return Response(status=status.HTTP_200_OK) @action(detail=True, methods=["post"]) def unlink_telegram(self, request, pk) -> Response: user = self.get_object() from apps.telegram.models import TelegramToUserConnector try: connector = TelegramToUserConnector.objects.get(user=user) connector.delete() write_chatops_insight_log( author=request.user, event_name=ChatOpsEvent.USER_UNLINKED, chatops_type=ChatOpsTypePlug.TELEGRAM.value, linked_user=user.username, linked_user_id=user.public_primary_key, ) except TelegramToUserConnector.DoesNotExist: return Response(status=status.HTTP_400_BAD_REQUEST) return Response(status=status.HTTP_200_OK) @extend_schema( parameters=[inline_serializer(name="UserUnlinkBackend", fields={"backend": serializers.CharField()})] ) @action(detail=True, methods=["post"]) def unlink_backend(self, request, pk) -> Response: # TODO: insight logs support user = self.get_object() backend_id = request.query_params.get("backend") backend = get_messaging_backend_from_id(backend_id) if backend is None: return Response(status=status.HTTP_400_BAD_REQUEST) try: backend.unlink_user(user) write_chatops_insight_log( author=request.user, event_name=ChatOpsEvent.USER_UNLINKED, chatops_type=backend.backend_id, linked_user=user.username, linked_user_id=user.public_primary_key, ) except ObjectDoesNotExist: return Response(status=status.HTTP_400_BAD_REQUEST) return Response(status=status.HTTP_200_OK) @extend_schema( parameters=[ inline_serializer( name="UserUpcomingShiftsParams", fields={"days": serializers.IntegerField(required=False, default=UPCOMING_SHIFTS_DEFAULT_DAYS)}, ) ], responses={status.HTTP_200_OK: resolve_type_hint(UpcomingShifts)}, ) @action(detail=True, methods=["get"]) def upcoming_shifts(self, request, pk) -> Response: user = self.get_object() try: days = int(request.query_params.get("days", UPCOMING_SHIFTS_DEFAULT_DAYS)) except ValueError: return Response(status=status.HTTP_400_BAD_REQUEST) if days <= 0 or days > UPCOMING_SHIFTS_MAX_DAYS: return Response(status=status.HTTP_400_BAD_REQUEST) now = timezone.now() # filter user-related schedules schedules = OnCallSchedule.objects.related_to_user(user) # check upcoming shifts upcoming = [] for schedule in schedules: _, current_shifts, upcoming_shifts = schedule.shifts_for_user(user, datetime_start=now, days=days) if current_shifts or upcoming_shifts: upcoming.append( { "schedule_id": schedule.public_primary_key, "schedule_name": schedule.name, "is_oncall": len(current_shifts) > 0, "current_shift": current_shifts[0] if current_shifts else None, "next_shift": upcoming_shifts[0] if upcoming_shifts else None, "upcoming_shifts": upcoming_shifts or None, } ) # sort entries by start timestamp def sorting_key(entry): shift = entry["current_shift"] if entry["current_shift"] else entry["next_shift"] return shift["start"] upcoming.sort(key=sorting_key) return Response(upcoming, status=status.HTTP_200_OK) @extend_schema( methods=["get"], responses=inline_serializer( name="UserExportTokenGetResponse", fields={ "created_at": serializers.DateTimeField(), "revoked_at": serializers.DateTimeField(allow_null=True), "active": serializers.BooleanField(), }, ), ) @extend_schema( methods=["post"], responses=inline_serializer( name="UserExportTokenPostResponse", fields={ "token": serializers.CharField(), "created_at": serializers.DateTimeField(), "export_url": serializers.CharField(), }, ), ) @action(detail=True, methods=["get", "post", "delete"]) def export_token(self, request, pk) -> Response: user = self.get_object() if self.request.method == "GET": try: token = UserScheduleExportAuthToken.objects.get(user=user) except UserScheduleExportAuthToken.DoesNotExist: raise NotFound response = { "created_at": token.created_at, "revoked_at": token.revoked_at, "active": token.active, } return Response(response, status=status.HTTP_200_OK) if self.request.method == "POST": try: instance, token = UserScheduleExportAuthToken.create_auth_token(user, user.organization) write_resource_insight_log(instance=instance, author=self.request.user, event=EntityEvent.CREATED) except IntegrityError: raise Conflict("Schedule export token for user already exists") export_url = create_engine_url( reverse("api-public:users-schedule-export", kwargs={"pk": user.public_primary_key}) + f"?{SCHEDULE_EXPORT_TOKEN_NAME}={token}" ) data = {"token": token, "created_at": instance.created_at, "export_url": export_url} return Response(data, status=status.HTTP_201_CREATED) if self.request.method == "DELETE": try: token = UserScheduleExportAuthToken.objects.get(user=user) write_resource_insight_log(instance=token, author=self.request.user, event=EntityEvent.DELETED) token.delete() except UserScheduleExportAuthToken.DoesNotExist: raise NotFound return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) @extend_schema( responses=inline_serializer( name="UserFilters", fields={ "name": serializers.CharField(), "type": serializers.CharField(), "href": serializers.CharField(required=False), "global": serializers.BooleanField(required=False), "default": serializers.JSONField(required=False), "description": serializers.CharField(required=False), "options": inline_serializer( name="UserFiltersOptions", fields={ "value": serializers.CharField(), "display_name": serializers.IntegerField(), }, ), }, many=True, ) ) @action(methods=["get"], detail=False) def filters(self, request): api_root = "/api/internal/v1/" filter_options = [ {"name": "search", "type": "search"}, { "name": "team", "type": "team_select", "href": api_root + "teams/", "global": True, }, ] return Response(filter_options) def handle_phone_notificator_failed(exc: BaseFailed) -> Response: if exc.graceful_msg: return Response(exc.graceful_msg, status=status.HTTP_400_BAD_REQUEST) else: return Response("Something went wrong", status=status.HTTP_503_SERVICE_UNAVAILABLE)