diff --git a/CHANGELOG.md b/CHANGELOG.md index d2201909a23..41a07415e72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Different resources (tasks, projects) are not visible anymore for all CVAT instance users by default () - API versioning scheme: using accept header versioning instead of namespace versioning () - Replaced 'django_sendfile' with 'django_sendfile2' () +- Use drf-spectacular instead of drf-yasg for swagger documentation () ### Deprecated - Job field "status" is not used in UI anymore, but it has not been removed from the database yet () diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index bbf6656a084..ede2bf71728 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -43,6 +43,10 @@ class StatusChoice(str, Enum): def choices(cls): return tuple((x.value, x.name) for x in cls) + @classmethod + def list(cls): + return list(map(lambda x: x.value, cls)) + def __str__(self): return self.value diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index 687d4d64e0e..19ba98a8805 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -17,6 +17,8 @@ from cvat.apps.engine.log import slogger from cvat.apps.engine.utils import parse_specific_attributes +from drf_spectacular.utils import OpenApiExample, extend_schema_serializer + class BasicUserSerializer(serializers.ModelSerializer): def validate(self, data): if hasattr(self, 'initial_data'): @@ -849,7 +851,7 @@ def to_internal_value(self, data): def to_representation(self, instance): return instance.filename if instance else instance -class BaseCloudStorageSerializer(serializers.ModelSerializer): +class CloudStorageReadSerializer(serializers.ModelSerializer): owner = BasicUserSerializer(required=False) manifests = ManifestSerializer(many=True, default=[]) class Meta: @@ -857,7 +859,70 @@ class Meta: exclude = ['credentials'] read_only_fields = ('created_date', 'updated_date', 'owner', 'organization') -class CloudStorageSerializer(serializers.ModelSerializer): +@extend_schema_serializer( + examples=[ + OpenApiExample( + 'Create AWS S3 cloud storage with credentials', + description='', + value={ + 'provider_type': models.CloudProviderChoice.AWS_S3, + 'resource': 'somebucket', + 'display_name': 'Bucket', + 'credentials_type': models.CredentialsTypeChoice.KEY_SECRET_KEY_PAIR, + 'specific_attributes': 'region=eu-central-1', + 'description': 'Some description', + 'manifests': [ + 'manifest.jsonl' + ], + + }, + request_only=True, + ), + OpenApiExample( + 'Create AWS S3 cloud storage without credentials', + value={ + 'provider_type': models.CloudProviderChoice.AWS_S3, + 'resource': 'somebucket', + 'display_name': 'Bucket', + 'credentials_type': models.CredentialsTypeChoice.ANONYMOUS_ACCESS, + 'manifests': [ + 'manifest.jsonl' + ], + }, + request_only=True, + ), + OpenApiExample( + 'Create Azure cloud storage', + value={ + 'provider_type': models.CloudProviderChoice.AZURE_CONTAINER, + 'resource': 'sonecontainer', + 'display_name': 'Container', + 'credentials_type': models.CredentialsTypeChoice.ACCOUNT_NAME_TOKEN_PAIR, + 'account_name': 'someaccount', + 'session_token': 'xxx', + 'manifests': [ + 'manifest.jsonl' + ], + }, + request_only=True, + ), + OpenApiExample( + 'Create GCS', + value={ + 'provider_type': models.CloudProviderChoice.GOOGLE_CLOUD_STORAGE, + 'resource': 'somebucket', + 'display_name': 'Bucket', + 'credentials_type': models.CredentialsTypeChoice.KEY_FILE_PATH, + 'key_file': 'file', + 'manifests': [ + 'manifest.jsonl' + ], + }, + request_only=True, + ) + ] +) +class CloudStorageWriteSerializer(serializers.ModelSerializer): owner = BasicUserSerializer(required=False) session_token = serializers.CharField(max_length=440, allow_blank=True, required=False) key = serializers.CharField(max_length=20, allow_blank=True, required=False) diff --git a/cvat/apps/engine/urls.py b/cvat/apps/engine/urls.py index c2918777fef..22eaf492cbe 100644 --- a/cvat/apps/engine/urls.py +++ b/cvat/apps/engine/urls.py @@ -6,42 +6,12 @@ from django.urls import path, include from . import views from rest_framework import routers -from rest_framework import permissions -from drf_yasg.views import get_schema_view -from drf_yasg import openapi + from django.views.generic import RedirectView from django.conf import settings from cvat.apps.restrictions.views import RestrictionsViewSet -from cvat.apps.iam.decorators import login_required - -schema_view = get_schema_view( - openapi.Info( - title="CVAT REST API", - default_version='v1', - description="REST API for Computer Vision Annotation Tool (CVAT)", - terms_of_service="https://www.google.com/policies/terms/", - contact=openapi.Contact(email="nikita.manovich@intel.com"), - license=openapi.License(name="MIT License"), - ), - public=True, - permission_classes=(permissions.IsAuthenticated,), -) - -# drf-yasg component doesn't handle correctly URL_FORMAT_OVERRIDE and -# send requests with ?format=openapi suffix instead of ?scheme=openapi. -# We map the required parameter explicitly and add it into query arguments -# on the server side. -def wrap_swagger(view): - @login_required - def _map_format_to_schema(request, scheme=None): - if 'format' in request.GET: - request.GET = request.GET.copy() - format_alias = settings.REST_FRAMEWORK['URL_FORMAT_OVERRIDE'] - request.GET[format_alias] = request.GET['format'] - - return view(request, format=scheme) - return _map_format_to_schema +from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView router = routers.DefaultRouter(trailing_slash=False) router.register('projects', views.ProjectViewSet) @@ -60,12 +30,9 @@ def _map_format_to_schema(request, scheme=None): query_string=True)), # documentation for API - path('api/swagger', wrap_swagger( - schema_view.without_ui(cache_timeout=0)), name='schema-json'), - path('api/swagger/', wrap_swagger( - schema_view.with_ui('swagger', cache_timeout=0)), name='schema-swagger-ui'), - path('api/docs/', wrap_swagger( - schema_view.with_ui('redoc', cache_timeout=0)), name='schema-redoc'), + path('api/schema/', SpectacularAPIView.as_view(api_version='2.0'), name='schema'), + path('api/swagger/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger'), + path('api/docs/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), # entry point for API path('api/', include('cvat.apps.iam.urls')), diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index 93efd5c1a33..dd845738d1a 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -22,12 +22,14 @@ from django.db import IntegrityError from django.http import HttpResponse, HttpResponseNotFound, HttpResponseBadRequest from django.utils import timezone -from django.utils.decorators import method_decorator from django_filters import rest_framework as filters -from django_filters.rest_framework import DjangoFilterBackend -from drf_yasg import openapi -from drf_yasg.inspectors import CoreAPICompatInspector, NotHandled, FieldInspector -from drf_yasg.utils import swagger_auto_schema + +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import ( + OpenApiParameter, OpenApiResponse, PolymorphicProxySerializer, + extend_schema_view, extend_schema +) + from rest_framework import mixins, serializers, status, viewsets from rest_framework.decorators import action from rest_framework.exceptions import APIException, NotFound, ValidationError @@ -39,7 +41,7 @@ import cvat.apps.dataset_manager as dm import cvat.apps.dataset_manager.views # pylint: disable=unused-import -from cvat.apps.engine.cloud_provider import get_cloud_storage_instance, Credentials, Status +from cvat.apps.engine.cloud_provider import get_cloud_storage_instance, Credentials, Status as CloudStorageStatus from cvat.apps.dataset_manager.bindings import CvatImportError from cvat.apps.dataset_manager.serializers import DatasetFormatsSerializer from cvat.apps.engine.frame_provider import FrameProvider @@ -57,8 +59,8 @@ FileInfoSerializer, JobReadSerializer, JobWriteSerializer, LabeledDataSerializer, LogEventSerializer, ProjectSerializer, ProjectSearchSerializer, RqStatusSerializer, TaskSerializer, UserSerializer, PluginsSerializer, IssueReadSerializer, - IssueWriteSerializer, CommentReadSerializer, CommentWriteSerializer, CloudStorageSerializer, - BaseCloudStorageSerializer, DatasetFileSerializer) + IssueWriteSerializer, CommentReadSerializer, CommentWriteSerializer, CloudStorageWriteSerializer, + CloudStorageReadSerializer, DatasetFileSerializer) from utils.dataset_manifest import ImageManifestManager from cvat.apps.engine.utils import av_scan_paths @@ -81,8 +83,11 @@ def get_serializer(self, *args, **kwargs): pass @staticmethod - @swagger_auto_schema(method='get', operation_summary='Method provides basic CVAT information', - responses={'200': AboutSerializer}) + @extend_schema(summary='Method provides basic CVAT information', + responses={ + '200': AboutSerializer, + }, + tags=['server'], versions=['2.0']) @action(detail=False, methods=['GET'], serializer_class=AboutSerializer) def about(request): from cvat import __version__ as cvat_version @@ -102,13 +107,14 @@ def about(request): return Response(data=serializer.data) @staticmethod - @swagger_auto_schema(method='post', request_body=ExceptionSerializer) + @extend_schema(summary='Method saves an exception from a client on the server', + description='Sends logs to the ELK if it is connected', + request=ExceptionSerializer, responses={ + '201': ExceptionSerializer, + }, + tags=['server'], versions=['2.0']) @action(detail=False, methods=['POST'], serializer_class=ExceptionSerializer) def exception(request): - """ - Saves an exception from a client on the server - Sends logs to the ELK if it is connected - """ serializer = ExceptionSerializer(data=request.data) if serializer.is_valid(raise_exception=True): additional_info = { @@ -128,13 +134,14 @@ def exception(request): return Response(serializer.data, status=status.HTTP_201_CREATED) @staticmethod - @swagger_auto_schema(method='post', request_body=LogEventSerializer(many=True)) + @extend_schema(summary='Method saves logs from a client on the server', + description='Sends logs to the ELK if it is connected', + request=LogEventSerializer(many=True), + responses={ + '201': LogEventSerializer(many=True), + }, tags=['server'], versions=['2.0']) @action(detail=False, methods=['POST'], serializer_class=LogEventSerializer) def logs(request): - """ - Saves logs from a client on the server - Sends logs to the ELK if it is connected - """ serializer = LogEventSerializer(many=True, data=request.data) if serializer.is_valid(raise_exception=True): user = { "username": request.user.username } @@ -151,11 +158,15 @@ def logs(request): return Response(serializer.data, status=status.HTTP_201_CREATED) @staticmethod - @swagger_auto_schema( - method='get', operation_summary='Returns all files and folders that are on the server along specified path', - manual_parameters=[openapi.Parameter('directory', openapi.IN_QUERY, type=openapi.TYPE_STRING, description='Directory to browse')], - responses={'200' : FileInfoSerializer(many=True)} - ) + @extend_schema( + summary='Returns all files and folders that are on the server along specified path', + parameters=[ + OpenApiParameter('directory', description='Directory to browse', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR) + ], + responses={ + '200' : FileInfoSerializer(many=True) + }, tags=['server'], versions=['2.0']) @action(detail=False, methods=['GET'], serializer_class=FileInfoSerializer) def share(request): param = request.query_params.get('directory', '/') @@ -184,16 +195,22 @@ def share(request): status=status.HTTP_400_BAD_REQUEST) @staticmethod - @swagger_auto_schema(method='get', operation_summary='Method provides the list of supported annotations formats', - responses={'200': DatasetFormatsSerializer()}) + @extend_schema( + summary='Method provides the list of supported annotations formats', + responses={ + '200': DatasetFormatsSerializer, + }, tags=['server'], versions=['2.0']) @action(detail=False, methods=['GET'], url_path='annotation/formats') def annotation_formats(request): data = dm.views.get_all_formats() return Response(DatasetFormatsSerializer(data).data) @staticmethod - @swagger_auto_schema(method='get', operation_summary='Method provides allowed plugins.', - responses={'200': PluginsSerializer()}) + @extend_schema( + summary='Method provides allowed plugins', + responses={ + '200': PluginsSerializer, + }, tags=['server'], versions=['2.0']) @action(detail=False, methods=['GET'], url_path='plugins', serializer_class=PluginsSerializer) def plugins(request): response = { @@ -215,23 +232,46 @@ class Meta: model = models.Project fields = ("id", "name", "owner", "status") -@method_decorator(name='list', decorator=swagger_auto_schema( - operation_summary='Returns a paginated list of projects according to query parameters (12 projects per page)', - manual_parameters=[ - openapi.Parameter('id', openapi.IN_QUERY, description="A unique number value identifying this project", - type=openapi.TYPE_NUMBER), - openapi.Parameter('name', openapi.IN_QUERY, description="Find all projects where name contains a parameter value", - type=openapi.TYPE_STRING), - openapi.Parameter('owner', openapi.IN_QUERY, description="Find all project where owner name contains a parameter value", - type=openapi.TYPE_STRING), - openapi.Parameter('status', openapi.IN_QUERY, description="Find all projects with a specific status", - type=openapi.TYPE_STRING, enum=[str(i) for i in StatusChoice]), - openapi.Parameter('names_only', openapi.IN_QUERY, description="Returns only names and id's of projects.", - type=openapi.TYPE_BOOLEAN)])) -@method_decorator(name='create', decorator=swagger_auto_schema(operation_summary='Method creates a new project')) -@method_decorator(name='retrieve', decorator=swagger_auto_schema(operation_summary='Method returns details of a specific project')) -@method_decorator(name='destroy', decorator=swagger_auto_schema(operation_summary='Method deletes a specific project')) -@method_decorator(name='partial_update', decorator=swagger_auto_schema(operation_summary='Methods does a partial update of chosen fields in a project')) +@extend_schema_view(list=extend_schema( + summary='Returns a paginated list of projects according to query parameters (12 projects per page)', + parameters=[ + OpenApiParameter('id', description='A unique number value identifying this project', + location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER), + OpenApiParameter('name', description='Find all projects where name contains a parameter value', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR), + OpenApiParameter('owner', description='Find all project where owner name contains a parameter value', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR), + OpenApiParameter('status', description='Find all projects with a specific status', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, enum=StatusChoice.list()), + OpenApiParameter('names_only', description="Returns only names and id's of projects", + location=OpenApiParameter.QUERY, type=OpenApiTypes.BOOL) + ], + responses={ + '200': PolymorphicProxySerializer(component_name='PolymorphicProject', + serializers=[ + ProjectSerializer, ProjectSearchSerializer, + ], resource_type_field_name='name', many=True), + }, tags=['projects'], versions=['2.0'])) +@extend_schema_view(create=extend_schema( + summary='Method creates a new project', + responses={ + '201': ProjectSerializer, + }, tags=['projects'], versions=['2.0'])) +@extend_schema_view(retrieve=extend_schema( + summary='Method returns details of a specific project', + responses={ + '200': ProjectSerializer, + }, tags=['projects'], versions=['2.0'])) +@extend_schema_view(destroy=extend_schema( + summary='Method deletes a specific project', + responses={ + '204': OpenApiResponse(description='The project has been deleted'), + }, tags=['projects'], versions=['2.0'])) +@extend_schema_view(partial_update=extend_schema( + summary='Methods does a partial update of chosen fields in a project', + responses={ + '200': ProjectSerializer, + }, tags=['projects'], versions=['2.0'])) class ProjectViewSet(viewsets.ModelViewSet): queryset = models.Project.objects.prefetch_related(Prefetch('label_set', queryset=models.Label.objects.order_by('id') @@ -263,10 +303,11 @@ def perform_create(self, serializer): serializer.save(owner=self.request.user, organization=self.request.iam_context['organization']) - @swagger_auto_schema( - method='get', - operation_summary='Returns information of the tasks of the project with the selected id', - responses={'200': TaskSerializer(many=True)}) + @extend_schema( + summary='Method returns information of the tasks of the project with the selected id', + responses={ + '200': TaskSerializer(many=True), + }, tags=['projects'], versions=['2.0']) @action(detail=True, methods=['GET'], serializer_class=TaskSerializer) def tasks(self, request, pk): self.get_object() # force to call check_object_permissions @@ -283,35 +324,33 @@ def tasks(self, request, pk): return Response(serializer.data) - @swagger_auto_schema(method='get', operation_summary='Export project as a dataset in a specific format', - manual_parameters=[ - openapi.Parameter('format', openapi.IN_QUERY, - description="Desired output format name\nYou can get the list of supported formats at:\n/server/annotation/formats", - type=openapi.TYPE_STRING, required=True), - openapi.Parameter('filename', openapi.IN_QUERY, - description="Desired output file name", - type=openapi.TYPE_STRING, required=False), - openapi.Parameter('action', in_=openapi.IN_QUERY, - description='Used to start downloading process after annotation file had been created', - type=openapi.TYPE_STRING, required=False, enum=['download', 'import_status']) + @extend_schema(methods=['GET'], summary='Export project as a dataset in a specific format', + parameters=[ + OpenApiParameter('format', description='Desired output format name\n' + 'You can get the list of supported formats at:\n/server/annotation/formats', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=True), + OpenApiParameter('filename', description='Desired output file name', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False), + OpenApiParameter('action', description='Used to start downloading process after annotation file had been created', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False, enum=['download', 'import_status']) ], - responses={'202': openapi.Response(description='Exporting has been started'), - '201': openapi.Response(description='Output file is ready for downloading'), - '200': openapi.Response(description='Download of file started'), - '405': openapi.Response(description='Format is not available'), - } - ) - @swagger_auto_schema(method='post', operation_summary='Import dataset in specific format as a project', - manual_parameters=[ - openapi.Parameter('format', openapi.IN_QUERY, - description="Desired dataset format name\nYou can get the list of supported formats at:\n/server/annotation/formats", - type=openapi.TYPE_STRING, required=True) + responses={ + '200': OpenApiResponse(description='Download of file started'), + '201': OpenApiResponse(description='Output file is ready for downloading'), + '202': OpenApiResponse(description='Exporting has been started'), + '405': OpenApiResponse(description='Format is not available'), + }, tags=['projects'], versions=['2.0']) + @extend_schema(methods=['POST'], summary='Import dataset in specific format as a project', + parameters=[ + OpenApiParameter('format', description='Desired dataset format name\n' + 'You can get the list of supported formats at:\n/server/annotation/formats', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=True) ], - responses={'202': openapi.Response(description='Exporting has been started'), - '400': openapi.Response(description='Failed to import dataset'), - '405': openapi.Response(description='Format is not available'), - } - ) + responses={ + '202': OpenApiResponse(description='Exporting has been started'), + '400': OpenApiResponse(description='Failed to import dataset'), + '405': OpenApiResponse(description='Format is not available'), + }, tags=['projects'], versions=['2.0']) @action(detail=True, methods=['GET', 'POST'], serializer_class=None, url_path='dataset') def dataset(self, request, pk): @@ -364,26 +403,23 @@ def dataset(self, request, pk): filename=request.query_params.get("filename", "").lower(), ) - @swagger_auto_schema(method='get', operation_summary='Method allows to download project annotations', - manual_parameters=[ - openapi.Parameter('format', openapi.IN_QUERY, - description="Desired output format name\nYou can get the list of supported formats at:\n/server/annotation/formats", - type=openapi.TYPE_STRING, required=True), - openapi.Parameter('filename', openapi.IN_QUERY, - description="Desired output file name", - type=openapi.TYPE_STRING, required=False), - openapi.Parameter('action', in_=openapi.IN_QUERY, - description='Used to start downloading process after annotation file had been created', - type=openapi.TYPE_STRING, required=False, enum=['download']) + @extend_schema(summary='Method allows to download project annotations', + parameters=[ + OpenApiParameter('format', description='Desired output format name\n' + 'You can get the list of supported formats at:\n/server/annotation/formats', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=True), + OpenApiParameter('filename', description='Desired output file name', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False), + OpenApiParameter('action', description='Used to start downloading process after annotation file had been created', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False, enum=['download']) ], responses={ - '202': openapi.Response(description='Dump of annotations has been started'), - '201': openapi.Response(description='Annotations file is ready to download'), - '200': openapi.Response(description='Download of file started'), - '405': openapi.Response(description='Format is not available'), - '401': openapi.Response(description='Format is not specified'), - } - ) + '200': OpenApiResponse(description='Download of file started'), + '201': OpenApiResponse(description='Annotations file is ready to download'), + '202': OpenApiResponse(description='Dump of annotations has been started'), + '401': OpenApiResponse(description='Format is not specified'), + '405': OpenApiResponse(description='Format is not available'), + }, tags=['projects'], versions=['2.0']) @action(detail=True, methods=['GET'], serializer_class=LabeledDataSerializer) def annotations(self, request, pk): @@ -401,11 +437,22 @@ def annotations(self, request, pk): else: return Response("Format is not specified",status=status.HTTP_400_BAD_REQUEST) + @extend_schema(summary='Methods creates a backup copy of a project', + responses={ + '200': OpenApiResponse(description='Download of file started'), + '201': OpenApiResponse(description='Output backup file is ready for downloading'), + '202': OpenApiResponse(description='Creating a backup file has been started'), + }, tags=['projects'], versions=['2.0']) @action(methods=['GET'], detail=True, url_path='backup') def export_backup(self, request, pk=None): db_project = self.get_object() # force to call check_object_permissions return backup.export(db_project, request) + @extend_schema(summary='Methods create a project from a backup', + responses={ + '201': OpenApiResponse(description='The project has been imported'), # or better specify {id: project_id} + '202': OpenApiResponse(description='Importing a backup file has been started'), + }, tags=['projects'], versions=['2.0']) @action(detail=False, methods=['POST'], url_path='backup') def import_backup(self, request, pk=None): return backup.import_project(request) @@ -516,35 +563,48 @@ class Meta: fields = ("id", "project_id", "project", "name", "owner", "mode", "status", "assignee") -class DjangoFilterInspector(CoreAPICompatInspector): - def get_filter_parameters(self, filter_backend): - if isinstance(filter_backend, DjangoFilterBackend): - result = super(DjangoFilterInspector, self).get_filter_parameters(filter_backend) - res = result.copy() - - for param in result: - if param.get('name') == 'project_id' or param.get('name') == 'project': - res.remove(param) - return res - - return NotHandled - -@method_decorator(name='list', decorator=swagger_auto_schema( - operation_summary='Returns a paginated list of tasks according to query parameters (10 tasks per page)', - manual_parameters=[ - openapi.Parameter('id',openapi.IN_QUERY,description="A unique number value identifying this task",type=openapi.TYPE_NUMBER), - openapi.Parameter('name', openapi.IN_QUERY, description="Find all tasks where name contains a parameter value", type=openapi.TYPE_STRING), - openapi.Parameter('owner', openapi.IN_QUERY, description="Find all tasks where owner name contains a parameter value", type=openapi.TYPE_STRING), - openapi.Parameter('mode', openapi.IN_QUERY, description="Find all tasks with a specific mode", type=openapi.TYPE_STRING, enum=['annotation', 'interpolation']), - openapi.Parameter('status', openapi.IN_QUERY, description="Find all tasks with a specific status", type=openapi.TYPE_STRING,enum=['annotation','validation','completed']), - openapi.Parameter('assignee', openapi.IN_QUERY, description="Find all tasks where assignee name contains a parameter value", type=openapi.TYPE_STRING) - ], - filter_inspectors=[DjangoFilterInspector])) -@method_decorator(name='create', decorator=swagger_auto_schema(operation_summary='Method creates a new task in a database without any attached images and videos')) -@method_decorator(name='retrieve', decorator=swagger_auto_schema(operation_summary='Method returns details of a specific task')) -@method_decorator(name='update', decorator=swagger_auto_schema(operation_summary='Method updates a task by id')) -@method_decorator(name='destroy', decorator=swagger_auto_schema(operation_summary='Method deletes a specific task, all attached jobs, annotations, and data')) -@method_decorator(name='partial_update', decorator=swagger_auto_schema(operation_summary='Methods does a partial update of chosen fields in a task')) +@extend_schema_view(list=extend_schema( + summary='Returns a paginated list of tasks according to query parameters (10 tasks per page)', + parameters=[ + OpenApiParameter('id', description='A unique number value identifying this task', + location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER), + OpenApiParameter('name', description='Find all tasks where name contains a parameter value', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR), + OpenApiParameter('owner', description='Find all tasks where owner name contains a parameter value', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR), + OpenApiParameter('mode', description='Find all tasks with a specific mode', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, enum=['annotation', 'interpolation']), + OpenApiParameter('status', description='Find all tasks with a specific status', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, enum=StatusChoice.list()), + OpenApiParameter('assignee', description='Find all tasks where assignee name contains a parameter value', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR) + ], + responses={ + '200': TaskSerializer(many=True), + }, tags=['tasks'], versions=['2.0'])) +@extend_schema_view(create=extend_schema( + summary='Method creates a new task in a database without any attached images and videos', + responses={ + '201': TaskSerializer, + }, tags=['tasks'], versions=['2.0'])) +@extend_schema_view(retrieve=extend_schema( + summary='Method returns details of a specific task', + responses=TaskSerializer, tags=['tasks'], versions=['2.0'])) +@extend_schema_view(update=extend_schema( + summary='Method updates a task by id', + responses={ + '200': TaskSerializer, + }, tags=['tasks'], versions=['2.0'])) +@extend_schema_view(destroy=extend_schema( + summary='Method deletes a specific task, all attached jobs, annotations, and data', + responses={ + '204': OpenApiResponse(description='The task has been deleted'), + }, tags=['tasks'], versions=['2.0'])) +@extend_schema_view(partial_update=extend_schema( + summary='Methods does a partial update of chosen fields in a task', + responses={ + '200': TaskSerializer, + }, tags=['tasks'], versions=['2.0'])) class TaskViewSet(UploadMixin, viewsets.ModelViewSet): queryset = Task.objects.prefetch_related( Prefetch('label_set', queryset=models.Label.objects.order_by('id')), @@ -565,10 +625,21 @@ def get_queryset(self): return queryset + @extend_schema(summary='Method recreates a task from an attached task backup file', + responses={ + '201': OpenApiResponse(description='The task has been imported'), # or better specify {id: task_id} + '202': OpenApiResponse(description='Importing a backup file has been started'), + }, tags=['tasks'], versions=['2.0']) @action(detail=False, methods=['POST'], url_path='backup') def import_backup(self, request, pk=None): return backup.import_task(request) + @extend_schema(summary='Method backup a specified task', + responses={ + '200': OpenApiResponse(description='Download of file started'), + '201': OpenApiResponse(description='Output backup file is ready for downloading'), + '202': OpenApiResponse(description='Creating a backup file has been started'), + }, tags=['tasks'], versions=['2.0']) @action(methods=['GET'], detail=True, url_path='backup') def export_backup(self, request, pk=None): db_task = self.get_object() # force to call check_object_permissions @@ -601,10 +672,10 @@ def perform_destroy(self, instance): db_project = instance.project db_project.save() - @swagger_auto_schema( - method='get', - operation_summary='Returns a list of jobs for a specific task', - responses={'200': JobReadSerializer(many=True)}) + @extend_schema(summary='Method returns a list of jobs for a specific task', + responses={ + '200': JobReadSerializer(many=True), + }, tags=['tasks'], versions=['2.0']) @action(detail=True, methods=['GET'], serializer_class=JobReadSerializer) def jobs(self, request, pk): self.get_object() # force to call check_object_permissions @@ -648,29 +719,34 @@ def upload_finished(self, request): task.create(db_task.id, data) return Response(serializer.data, status=status.HTTP_202_ACCEPTED) - @swagger_auto_schema(method='post', operation_summary='Method permanently attaches images or video to a task. Supports tus uploads, see more https://tus.io/', - request_body=DataSerializer, - manual_parameters=[ - openapi.Parameter('Upload-Start', in_=openapi.IN_HEADER, type=openapi.TYPE_BOOLEAN, - description="Initializes data upload. No data should be sent with this header"), - openapi.Parameter('Upload-Multiple', in_=openapi.IN_HEADER, type=openapi.TYPE_BOOLEAN, - description="Indicates that data with this request are single or multiple files that should be attached to a task"), - openapi.Parameter('Upload-Finish', in_=openapi.IN_HEADER, type=openapi.TYPE_BOOLEAN, - description="Finishes data upload. Can be combined with Upload-Start header to create task data with one request"), - ] - ) - @swagger_auto_schema(method='get', operation_summary='Method returns data for a specific task', - manual_parameters=[ - openapi.Parameter('type', in_=openapi.IN_QUERY, required=True, type=openapi.TYPE_STRING, - enum=['chunk', 'frame', 'preview', 'context_image'], - description="Specifies the type of the requested data"), - openapi.Parameter('quality', in_=openapi.IN_QUERY, required=True, type=openapi.TYPE_STRING, - enum=['compressed', 'original'], + @extend_schema(methods=['POST'], + summary='Method permanently attaches images or video to a task. Supports tus uploads, see more https://tus.io/', + request=DataSerializer, + parameters=[ + OpenApiParameter('Upload-Start', location=OpenApiParameter.HEADER, type=OpenApiTypes.BOOL, + description='Initializes data upload. No data should be sent with this header'), + OpenApiParameter('Upload-Multiple', location=OpenApiParameter.HEADER, type=OpenApiTypes.BOOL, + description='Indicates that data with this request are single or multiple files that should be attached to a task'), + OpenApiParameter('Upload-Finish', location=OpenApiParameter.HEADER, type=OpenApiTypes.BOOL, + description='Finishes data upload. Can be combined with Upload-Start header to create task data with one request'), + ], + responses={ + '202': OpenApiResponse(description=''), + }, tags=['tasks'], versions=['2.0']) + @extend_schema(methods=['GET'], summary='Method returns data for a specific task', + parameters=[ + OpenApiParameter('type', location=OpenApiParameter.QUERY, required=True, + type=OpenApiTypes.STR, enum=['chunk', 'frame', 'preview', 'context_image'], + description='Specifies the type of the requested data'), + OpenApiParameter('quality', location=OpenApiParameter.QUERY, required=True, + type=OpenApiTypes.STR, enum=['compressed', 'original'], description="Specifies the quality level of the requested data, doesn't matter for 'preview' type"), - openapi.Parameter('number', in_=openapi.IN_QUERY, required=True, type=openapi.TYPE_NUMBER, + OpenApiParameter('number', location=OpenApiParameter.QUERY, required=True, type=OpenApiTypes.NUMBER, description="A unique number value identifying chunk or frame, doesn't matter for 'preview' type"), - ] - ) + ], + responses={ + '200': OpenApiResponse(description='Data of a specific type'), + }, tags=['tasks'], versions=['2.0']) @action(detail=True, methods=['OPTIONS', 'POST', 'GET'], url_path=r'data/?$') def data(self, request, pk): db_task = self.get_object() # call check_object_permissions as well @@ -697,41 +773,41 @@ def data(self, request, pk): return data_getter(request, db_task.data.start_frame, db_task.data.stop_frame, db_task.data) - @swagger_auto_schema(method='get', operation_summary='Method allows to download task annotations', - manual_parameters=[ - openapi.Parameter('format', openapi.IN_QUERY, - description="Desired output format name\nYou can get the list of supported formats at:\n/server/annotation/formats", - type=openapi.TYPE_STRING, required=False), - openapi.Parameter('filename', openapi.IN_QUERY, - description="Desired output file name", - type=openapi.TYPE_STRING, required=False), - openapi.Parameter('action', in_=openapi.IN_QUERY, + @extend_schema(methods=['GET'], summary='Method allows to download task annotations', + parameters=[ + OpenApiParameter('format', location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False, + description="Desired output format name\nYou can get the list of supported formats at:\n/server/annotation/formats"), + OpenApiParameter('filename', description='Desired output file name', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False), + OpenApiParameter('action', location=OpenApiParameter.QUERY, description='Used to start downloading process after annotation file had been created', - type=openapi.TYPE_STRING, required=False, enum=['download']) + type=OpenApiTypes.STR, required=False, enum=['download']) ], responses={ - '202': openapi.Response(description='Dump of annotations has been started'), - '201': openapi.Response(description='Annotations file is ready to download'), - '200': openapi.Response(description='Download of file started'), - '405': openapi.Response(description='Format is not available'), - } - ) - @swagger_auto_schema(method='put', operation_summary='Method allows to upload task annotations', - manual_parameters=[ - openapi.Parameter('format', openapi.IN_QUERY, - description="Input format name\nYou can get the list of supported formats at:\n/server/annotation/formats", - type=openapi.TYPE_STRING, required=False), + '200': OpenApiResponse(description='Download of file started'), + '201': OpenApiResponse(description='Annotations file is ready to download'), + '202': OpenApiResponse(description='Dump of annotations has been started'), + '405': OpenApiResponse(description='Format is not available'), + }, tags=['tasks'], versions=['2.0']) + @extend_schema(methods=['PUT'], summary='Method allows to upload task annotations', + parameters=[ + OpenApiParameter('format', location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False, + description='Input format name\nYou can get the list of supported formats at:\n/server/annotation/formats'), ], responses={ - '202': openapi.Response(description='Uploading has been started'), - '201': openapi.Response(description='Uploading has finished'), - '405': openapi.Response(description='Format is not available'), - } - ) - @swagger_auto_schema(method='patch', operation_summary='Method performs a partial update of annotations in a specific task', - manual_parameters=[openapi.Parameter('action', in_=openapi.IN_QUERY, required=True, type=openapi.TYPE_STRING, - enum=['create', 'update', 'delete'])]) - @swagger_auto_schema(method='delete', operation_summary='Method deletes all annotations for a specific task') + '201': OpenApiResponse(description='Uploading has finished'), + '202': OpenApiResponse(description='Uploading has been started'), + '405': OpenApiResponse(description='Format is not available'), + }, tags=['tasks'], versions=['2.0']) + @extend_schema(methods=['PATCH'], summary='Method performs a partial update of annotations in a specific task', + parameters=[ + OpenApiParameter('action', location=OpenApiParameter.QUERY, required=True, + type=OpenApiTypes.STR, enum=['create', 'update', 'delete']), + ], tags=['tasks'], versions=['2.0']) + @extend_schema(methods=['DELETE'], summary='Method deletes all annotations for a specific task', + responses={ + '204': OpenApiResponse(description='The annotation has been deleted'), + }, tags=['tasks'], versions=['2.0']) @action(detail=True, methods=['GET', 'DELETE', 'PUT', 'PATCH'], serializer_class=LabeledDataSerializer) def annotations(self, request, pk): @@ -783,7 +859,11 @@ def annotations(self, request, pk): return Response(data=str(e), status=status.HTTP_400_BAD_REQUEST) return Response(data) - @swagger_auto_schema(method='get', operation_summary='When task is being created the method returns information about a status of the creation process') + @extend_schema( + summary='When task is being created the method returns information about a status of the creation process', + responses={ + '200': RqStatusSerializer, + }, tags=['tasks'], versions=['2.0']) @action(detail=True, methods=['GET'], serializer_class=RqStatusSerializer) def status(self, request, pk): self.get_object() # force to call check_object_permissions @@ -813,8 +893,10 @@ def _get_rq_response(queue, job_id): return response @staticmethod - @swagger_auto_schema(method='get', operation_summary='Method provides a meta information about media files which are related with the task', - responses={'200': DataMetaSerializer()}) + @extend_schema(summary='Method provides a meta information about media files which are related with the task', + responses={ + '200': DataMetaSerializer, + }, tags=['tasks'], versions=['2.0']) @action(detail=True, methods=['GET'], serializer_class=DataMetaSerializer, url_path='data/meta') def data_info(request, pk): @@ -842,24 +924,23 @@ def data_info(request, pk): serializer = DataMetaSerializer(db_data) return Response(serializer.data) - @swagger_auto_schema(method='get', operation_summary='Export task as a dataset in a specific format', - manual_parameters=[ - openapi.Parameter('format', openapi.IN_QUERY, - description="Desired output format name\nYou can get the list of supported formats at:\n/server/annotation/formats", - type=openapi.TYPE_STRING, required=True), - openapi.Parameter('filename', openapi.IN_QUERY, - description="Desired output file name", - type=openapi.TYPE_STRING, required=False), - openapi.Parameter('action', in_=openapi.IN_QUERY, + @extend_schema(summary='Export task as a dataset in a specific format', + parameters=[ + OpenApiParameter('format', location=OpenApiParameter.QUERY, + description='Desired output format name\nYou can get the list of supported formats at:\n/server/annotation/formats', + type=OpenApiTypes.STR, required=True), + OpenApiParameter('filename', description='Desired output file name', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, required=False), + OpenApiParameter('action', location=OpenApiParameter.QUERY, description='Used to start downloading process after annotation file had been created', - type=openapi.TYPE_STRING, required=False, enum=['download']) + type=OpenApiTypes.STR, required=False, enum=['download']) ], - responses={'202': openapi.Response(description='Exporting has been started'), - '201': openapi.Response(description='Output file is ready for downloading'), - '200': openapi.Response(description='Download of file started'), - '405': openapi.Response(description='Format is not available'), - } - ) + responses={ + '200': OpenApiResponse(description='Download of file started'), + '201': OpenApiResponse(description='Output file is ready for downloading'), + '202': OpenApiResponse(description='Exporting has been started'), + '405': OpenApiResponse(description='Format is not available'), + }, tags=['tasks'], versions=['2.0']) @action(detail=True, methods=['GET'], serializer_class=None, url_path='dataset') def dataset_export(self, request, pk): @@ -887,10 +968,26 @@ class Meta: model = Job fields = ("assignee", ) -@method_decorator(name='retrieve', decorator=swagger_auto_schema(operation_summary='Method returns details of a job')) -@method_decorator(name='update', decorator=swagger_auto_schema(operation_summary='Method updates a job by id')) -@method_decorator(name='partial_update', decorator=swagger_auto_schema( - operation_summary='Methods does a partial update of chosen fields in a job')) +@extend_schema_view(retrieve=extend_schema( + summary='Method returns details of a job', + responses={ + '200': JobReadSerializer, + }, tags=['jobs'], versions=['2.0'])) +@extend_schema_view(list=extend_schema( + summary='Method returns a paginated list of jobs according to query parameters', + responses={ + '200': JobReadSerializer(many=True), + }, tags=['jobs'], versions=['2.0'])) +@extend_schema_view(update=extend_schema( + summary='Method updates a job by id', + responses={ + '200': JobWriteSerializer, + }, tags=['jobs'], versions=['2.0'])) +@extend_schema_view(partial_update=extend_schema( + summary='Methods does a partial update of chosen fields in a job', + responses={ + '200': JobWriteSerializer, + }, tags=['jobs'], versions=['2.0'])) class JobViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, mixins.RetrieveModelMixin, mixins.UpdateModelMixin): queryset = Job.objects.all().order_by('id') @@ -911,13 +1008,29 @@ def get_serializer_class(self): else: return JobWriteSerializer - @swagger_auto_schema(method='get', operation_summary='Method returns annotations for a specific job') - @swagger_auto_schema(method='put', operation_summary='Method performs an update of all annotations in a specific job') - @swagger_auto_schema(method='patch', manual_parameters=[ - openapi.Parameter('action', in_=openapi.IN_QUERY, type=openapi.TYPE_STRING, required=True, - enum=['create', 'update', 'delete'])], - operation_summary='Method performs a partial update of annotations in a specific job') - @swagger_auto_schema(method='delete', operation_summary='Method deletes all annotations for a specific job') + @extend_schema(methods=['GET'], summary='Method returns annotations for a specific job', + responses={ + '200': LabeledDataSerializer(many=True), + }, tags=['jobs'], versions=['2.0']) + @extend_schema(methods=['PUT'], summary='Method performs an update of all annotations in a specific job', + request=AnnotationFileSerializer, responses={ + '201': OpenApiResponse(description='Uploading has finished'), + '202': OpenApiResponse(description='Uploading has been started'), + '405': OpenApiResponse(description='Format is not available'), + }, tags=['jobs'], versions=['2.0']) + @extend_schema(methods=['PATCH'], summary='Method performs a partial update of annotations in a specific job', + parameters=[ + OpenApiParameter('action', location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, + required=True, enum=['create', 'update', 'delete']) + ], + responses={ + #TODO + '200': OpenApiResponse(description=''), + }, tags=['jobs'], versions=['2.0']) + @extend_schema(methods=['DELETE'], summary='Method deletes all annotations for a specific job', + responses={ + '204': OpenApiResponse(description='The annotation has been deleted'), + }, tags=['jobs'], versions=['2.0']) @action(detail=True, methods=['GET', 'DELETE', 'PUT', 'PATCH'], serializer_class=LabeledDataSerializer) def annotations(self, request, pk): @@ -959,10 +1072,11 @@ def annotations(self, request, pk): return Response(data=str(e), status=status.HTTP_400_BAD_REQUEST) return Response(data) - @swagger_auto_schema( - method='get', - operation_summary='Method returns list of issues for the job', - responses={'200': IssueReadSerializer(many=True)}) + @extend_schema( + summary='Method returns list of issues for the job', + responses={ + '200': IssueReadSerializer(many=True) + }, tags=['jobs'], versions=['2.0']) @action(detail=True, methods=['GET'], serializer_class=IssueReadSerializer) def issues(self, request, pk): db_job = self.get_object() @@ -972,19 +1086,21 @@ def issues(self, request, pk): return Response(serializer.data) - @swagger_auto_schema(method='get', operation_summary='Method returns data for a specific job', - manual_parameters=[ - openapi.Parameter('type', in_=openapi.IN_QUERY, required=True, type=openapi.TYPE_STRING, - enum=['chunk', 'frame', 'preview', 'context_image'], - description="Specifies the type of the requested data"), - openapi.Parameter('quality', in_=openapi.IN_QUERY, required=True, type=openapi.TYPE_STRING, - enum=['compressed', 'original'], + @extend_schema(summary='Method returns data for a specific job', + parameters=[ + OpenApiParameter('type', description='Specifies the type of the requested data', + location=OpenApiParameter.QUERY, required=True, type=OpenApiTypes.STR, + enum=['chunk', 'frame', 'preview', 'context_image']), + OpenApiParameter('quality', location=OpenApiParameter.QUERY, required=True, + type=OpenApiTypes.STR, enum=['compressed', 'original'], description="Specifies the quality level of the requested data, doesn't matter for 'preview' type"), - openapi.Parameter('number', in_=openapi.IN_QUERY, required=True, type=openapi.TYPE_NUMBER, + OpenApiParameter('number', location=OpenApiParameter.QUERY, required=True, type=OpenApiTypes.NUMBER, description="A unique number value identifying chunk or frame, doesn't matter for 'preview' type"), - ] - ) - @action(detail=True) + ], + responses={ + '200': OpenApiResponse(description='Data of a specific type'), + }, tags=['jobs'], versions=['2.0']) + @action(detail=True, methods=['GET']) def data(self, request, pk): db_job = self.get_object() # call check_object_permissions as well data_type = request.query_params.get('type', None) @@ -997,6 +1113,36 @@ def data(self, request, pk): return data_getter(request, db_job.segment.start_frame, db_job.segment.stop_frame, db_job.segment.task.data) +@extend_schema_view(retrieve=extend_schema( + summary='Method returns details of an issue', + responses={ + '200': IssueReadSerializer, + }, tags=['issues'], versions=['2.0'])) +@extend_schema_view(list=extend_schema( + summary='Method returns a paginated list of issues according to query parameters', + responses={ + '200': IssueReadSerializer(many=True), + }, tags=['issues'], versions=['2.0'])) +@extend_schema_view(update=extend_schema( + summary='Method updates an issue by id', + responses={ + '200': IssueWriteSerializer, + }, tags=['issues'], versions=['2.0'])) +@extend_schema_view(partial_update=extend_schema( + summary='Methods does a partial update of chosen fields in an issue', + responses={ + '200': IssueWriteSerializer, + }, tags=['issues'], versions=['2.0'])) +@extend_schema_view(create=extend_schema( + summary='Method creates an issue', + responses={ + '201': IssueWriteSerializer, + }, tags=['issues'], versions=['2.0'])) +@extend_schema_view(destroy=extend_schema( + summary='Method deletes an issue', + responses={ + '204': OpenApiResponse(description='The issue has been deleted'), + }, tags=['issues'], versions=['2.0'])) class IssueViewSet(viewsets.ModelViewSet): queryset = Issue.objects.all().order_by('-id') http_method_names = ['get', 'post', 'patch', 'delete', 'options'] @@ -1019,10 +1165,10 @@ def get_serializer_class(self): def perform_create(self, serializer): serializer.save(owner=self.request.user) - @swagger_auto_schema( - method='get', - operation_summary='The action returns all comments of a specific issue', - responses={'200': CommentReadSerializer(many=True)}) + @extend_schema(summary='The action returns all comments of a specific issue', + responses={ + '200': CommentReadSerializer(many=True), + }, tags=['issues'], versions=['2.0']) @action(detail=True, methods=['GET'], serializer_class=CommentReadSerializer) def comments(self, request, pk): db_issue = self.get_object() @@ -1032,6 +1178,36 @@ def comments(self, request, pk): return Response(serializer.data) +@extend_schema_view(retrieve=extend_schema( + summary='Method returns details of a comment', + responses={ + '200': CommentReadSerializer, + }, tags=['comments'], versions=['2.0'])) +@extend_schema_view(list=extend_schema( + summary='Method returns a paginated list of comments according to query parameters', + responses={ + '200':CommentReadSerializer(many=True), + }, tags=['comments'], versions=['2.0'])) +@extend_schema_view(update=extend_schema( + summary='Method updates a comment by id', + responses={ + '200': CommentWriteSerializer, + }, tags=['comments'], versions=['2.0'])) +@extend_schema_view(partial_update=extend_schema( + summary='Methods does a partial update of chosen fields in a comment', + responses={ + '200': CommentWriteSerializer, + }, tags=['comments'], versions=['2.0'])) +@extend_schema_view(create=extend_schema( + summary='Method creates a comment', + responses={ + '201': CommentWriteSerializer, + }, tags=['comments'], versions=['2.0'])) +@extend_schema_view(destroy=extend_schema( + summary='Method deletes a comment', + responses={ + '204': OpenApiResponse(description='The comment has been deleted'), + }, tags=['comments'], versions=['2.0'])) class CommentViewSet(viewsets.ModelViewSet): queryset = Comment.objects.all().order_by('-id') http_method_names = ['get', 'post', 'patch', 'delete', 'options'] @@ -1059,18 +1235,41 @@ class Meta: model = User fields = ("id", "is_active") -@method_decorator(name='list', decorator=swagger_auto_schema( - manual_parameters=[ - openapi.Parameter('id',openapi.IN_QUERY,description="A unique number value identifying this user",type=openapi.TYPE_NUMBER), - openapi.Parameter('is_active',openapi.IN_QUERY,description="Returns only active users",type=openapi.TYPE_BOOLEAN), +@extend_schema_view(list=extend_schema( + summary='Method provides a paginated list of users registered on the server', + parameters=[ + OpenApiParameter('id', description='A unique number value identifying this user', + location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER), + OpenApiParameter('is_active', description='Returns only active users', + location=OpenApiParameter.QUERY, type=OpenApiTypes.BOOL), ], - operation_summary='Method provides a paginated list of users registered on the server')) -@method_decorator(name='retrieve', decorator=swagger_auto_schema( - operation_summary='Method provides information of a specific user')) -@method_decorator(name='partial_update', decorator=swagger_auto_schema( - operation_summary='Method updates chosen fields of a user')) -@method_decorator(name='destroy', decorator=swagger_auto_schema( - operation_summary='Method deletes a specific user from the server')) + responses={ + '200': PolymorphicProxySerializer(component_name='MetaUser', + serializers=[ + UserSerializer, BasicUserSerializer, + ], resource_type_field_name='username'), + }, tags=['users'], versions=['2.0'])) +@extend_schema_view(retrieve=extend_schema( + summary='Method provides information of a specific user', + responses={ + '200': PolymorphicProxySerializer(component_name='MetaUser', + serializers=[ + UserSerializer, BasicUserSerializer, + ], resource_type_field_name='username'), + }, tags=['users'], versions=['2.0'])) +@extend_schema_view(partial_update=extend_schema( + summary='Method updates chosen fields of a user', + responses={ + '200': PolymorphicProxySerializer(component_name='MetaUser', + serializers=[ + UserSerializer, BasicUserSerializer, + ], resource_type_field_name='username'), + }, tags=['users'], versions=['2.0'])) +@extend_schema_view(destroy=extend_schema( + summary='Method deletes a specific user from the server', + responses={ + '204': OpenApiResponse(description='The user has been deleted'), + }, tags=['users'], versions=['2.0'])) class UserViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, mixins.RetrieveModelMixin, mixins.UpdateModelMixin, mixins.DestroyModelMixin): queryset = User.objects.prefetch_related('groups').all().order_by('id') @@ -1099,7 +1298,13 @@ def get_serializer_class(self): else: return BasicUserSerializer - @swagger_auto_schema(method='get', operation_summary='Method returns an instance of a user who is currently authorized') + @extend_schema(summary='Method returns an instance of a user who is currently authorized', + responses={ + '200': PolymorphicProxySerializer(component_name='MetaUser', + serializers=[ + UserSerializer, BasicUserSerializer, + ], resource_type_field_name='username'), + }, tags=['users'], versions=['2.0']) @action(detail=False, methods=['GET']) def self(self, request): """ @@ -1109,14 +1314,16 @@ def self(self, request): serializer = serializer_class(request.user, context={ "request": request }) return Response(serializer.data) -class RedefineDescriptionField(FieldInspector): - # pylint: disable=no-self-use - def process_result(self, result, method_name, obj, **kwargs): - if isinstance(result, openapi.Schema): - if hasattr(result, 'title') and result.title == 'Specific attributes': - result.description = 'structure like key1=value1&key2=value2\n' \ - 'supported: range=aws_range' - return result +# TODO: it will be good to find a way to define description using drf_spectacular. +# But now it will be enough to use an example +# class RedefineDescriptionField(FieldInspector): +# # pylint: disable=no-self-use +# def process_result(self, result, method_name, obj, **kwargs): +# if isinstance(result, openapi.Schema): +# if hasattr(result, 'title') and result.title == 'Specific attributes': +# result.description = 'structure like key1=value1&key2=value2\n' \ +# 'supported: range=aws_range' +# return result class CloudStorageFilter(filters.FilterSet): display_name = filters.CharFilter(field_name='display_name', lookup_expr='icontains') @@ -1130,42 +1337,49 @@ class Meta: model = models.CloudStorage fields = ('id', 'display_name', 'provider_type', 'resource', 'credentials_type', 'description', 'owner') -@method_decorator( - name='retrieve', - decorator=swagger_auto_schema( - operation_summary='Method returns details of a specific cloud storage', - responses={ - '200': openapi.Response(description='A details of a storage'), - }, - tags=['cloud storages'] - ) -) -@method_decorator(name='list', decorator=swagger_auto_schema( - operation_summary='Returns a paginated list of storages according to query parameters', - manual_parameters=[ - openapi.Parameter('provider_type', openapi.IN_QUERY, description="A supported provider of cloud storages", - type=openapi.TYPE_STRING, enum=CloudProviderChoice.list()), - openapi.Parameter('display_name', openapi.IN_QUERY, description="A display name of storage", type=openapi.TYPE_STRING), - openapi.Parameter('resource', openapi.IN_QUERY, description="A name of bucket or container", type=openapi.TYPE_STRING), - openapi.Parameter('owner', openapi.IN_QUERY, description="A resource owner", type=openapi.TYPE_STRING), - openapi.Parameter('credentials_type', openapi.IN_QUERY, description="A type of a granting access", type=openapi.TYPE_STRING, enum=CredentialsTypeChoice.list()), - ], - responses={'200': BaseCloudStorageSerializer(many=True)}, - tags=['cloud storages'], - field_inspectors=[RedefineDescriptionField] - ) -) -@method_decorator(name='destroy', decorator=swagger_auto_schema( - operation_summary='Method deletes a specific cloud storage', - tags=['cloud storages'] - ) -) -@method_decorator(name='partial_update', decorator=swagger_auto_schema( - operation_summary='Methods does a partial update of chosen fields in a cloud storage instance', - tags=['cloud storages'], - field_inspectors=[RedefineDescriptionField] - ) -) +@extend_schema_view(retrieve=extend_schema( + summary='Method returns details of a specific cloud storage', + responses={ + '200': CloudStorageReadSerializer, + }, tags=['cloud storages'], versions=['2.0'])) +@extend_schema_view(list=extend_schema( + summary='Returns a paginated list of storages according to query parameters', + parameters=[ + OpenApiParameter('provider_type', description='A supported provider of cloud storages', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, enum=CloudProviderChoice.list()), + OpenApiParameter('display_name', description='A display name of storage', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR), + OpenApiParameter('resource', description='A name of bucket or container', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR), + OpenApiParameter('owner', description='A resource owner', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR), + OpenApiParameter('credentials_type', description='A type of a granting access', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, enum=CredentialsTypeChoice.list()), + ], + #FIXME + #field_inspectors=[RedefineDescriptionField] + responses={ + '200': CloudStorageReadSerializer(many=True), + }, tags=['cloud storages'], versions=['2.0'])) +@extend_schema_view(destroy=extend_schema( + summary='Method deletes a specific cloud storage', + responses={ + '204': OpenApiResponse(description='The cloud storage has been removed'), + }, tags=['cloud storages'], versions=['2.0'])) +@extend_schema_view(partial_update=extend_schema( + summary='Methods does a partial update of chosen fields in a cloud storage instance', + # FIXME + #field_inspectors=[RedefineDescriptionField] + responses={ + '200': CloudStorageWriteSerializer, + }, tags=['cloud storages'], versions=['2.0'])) +@extend_schema_view(create=extend_schema( + summary='Method creates a cloud storage with a specified characteristics', + # FIXME + #field_inspectors=[RedefineDescriptionField], + responses={ + '201': CloudStorageWriteSerializer, + }, tags=['cloud storages'], versions=['2.0'])) class CloudStorageViewSet(viewsets.ModelViewSet): http_method_names = ['get', 'post', 'patch', 'delete'] queryset = CloudStorageModel.objects.all().prefetch_related('data').order_by('-id') @@ -1174,10 +1388,10 @@ class CloudStorageViewSet(viewsets.ModelViewSet): iam_organization_field = 'organization' def get_serializer_class(self): - if self.request.method in ("POST", "PATCH"): - return CloudStorageSerializer + if self.request.method in ('POST', 'PATCH'): + return CloudStorageWriteSerializer else: - return BaseCloudStorageSerializer + return CloudStorageReadSerializer def get_queryset(self): queryset = super().get_queryset() @@ -1202,15 +1416,6 @@ def perform_destroy(self, instance): super().perform_destroy(instance) shutil.rmtree(cloud_storage_dirname, ignore_errors=True) - @method_decorator(name='create', decorator=swagger_auto_schema( - operation_summary='Method creates a cloud storage with a specified characteristics', - responses={ - '201': openapi.Response(description='A storage has beed created') - }, - tags=['cloud storages'], - field_inspectors=[RedefineDescriptionField], - ) - ) def create(self, request, *args, **kwargs): try: response = super().create(request, *args, **kwargs) @@ -1229,19 +1434,14 @@ def create(self, request, *args, **kwargs): response = HttpResponseBadRequest(str(ex)) return response - @swagger_auto_schema( - method='get', - operation_summary='Method returns a manifest content', - manual_parameters=[ - openapi.Parameter('manifest_path', openapi.IN_QUERY, - description="Path to the manifest file in a cloud storage", - type=openapi.TYPE_STRING) + @extend_schema(summary='Method returns a manifest content', + parameters=[ + OpenApiParameter('manifest_path', description='Path to the manifest file in a cloud storage', + location=OpenApiParameter.QUERY, type=OpenApiTypes.STR), ], responses={ - '200': openapi.Response(description='A manifest content'), - }, - tags=['cloud storages'] - ) + '200': OpenApiResponse(response=OpenApiTypes.OBJECT, description='A manifest content'), + }, tags=['cloud storages'], versions=['2.0']) @action(detail=True, methods=['GET'], url_path='content') def content(self, request, pk): storage = None @@ -1262,10 +1462,10 @@ def content(self, request, pk): raise Exception('There is no manifest file') manifest_path = request.query_params.get('manifest_path', db_storage.manifests.first().filename) file_status = storage.get_file_status(manifest_path) - if file_status == Status.NOT_FOUND: + if file_status == CloudStorageStatus.NOT_FOUND: raise FileNotFoundError(errno.ENOENT, "Not found on the cloud storage {}".format(db_storage.display_name), manifest_path) - elif file_status == Status.FORBIDDEN: + elif file_status == CloudStorageStatus.FORBIDDEN: raise PermissionError(errno.EACCES, "Access to the file on the '{}' cloud storage is denied".format(db_storage.display_name), manifest_path) @@ -1290,22 +1490,18 @@ def content(self, request, pk): except Exception as ex: # check that cloud storage was not deleted storage_status = storage.get_status() if storage else None - if storage_status == Status.FORBIDDEN: + if storage_status == CloudStorageStatus.FORBIDDEN: msg = 'The resource {} is no longer available. Access forbidden.'.format(storage.name) - elif storage_status == Status.NOT_FOUND: + elif storage_status == CloudStorageStatus.NOT_FOUND: msg = 'The resource {} not found. It may have been deleted.'.format(storage.name) else: msg = str(ex) return HttpResponseBadRequest(msg) - @swagger_auto_schema( - method='get', - operation_summary='Method returns a preview image from a cloud storage', + @extend_schema(summary='Method returns a preview image from a cloud storage', responses={ - '200': openapi.Response(description='Preview'), - }, - tags=['cloud storages'] - ) + '200': OpenApiResponse(description='Cloud Storage preview'), + }, tags=['cloud storages'], versions=['2.0']) @action(detail=True, methods=['GET'], url_path='preview') def preview(self, request, pk): storage = None @@ -1348,10 +1544,10 @@ def preview(self, request, pk): return HttpResponseBadRequest(msg) file_status = storage.get_file_status(preview_path) - if file_status == Status.NOT_FOUND: + if file_status == CloudStorageStatus.NOT_FOUND: raise FileNotFoundError(errno.ENOENT, "Not found on the cloud storage {}".format(db_storage.display_name), preview_path) - elif file_status == Status.FORBIDDEN: + elif file_status == CloudStorageStatus.FORBIDDEN: raise PermissionError(errno.EACCES, "Access to the file on the '{}' cloud storage is denied".format(db_storage.display_name), preview_path) with NamedTemporaryFile() as temp_image: @@ -1370,22 +1566,18 @@ def preview(self, request, pk): except Exception as ex: # check that cloud storage was not deleted storage_status = storage.get_status() if storage else None - if storage_status == Status.FORBIDDEN: + if storage_status == CloudStorageStatus.FORBIDDEN: msg = 'The resource {} is no longer available. Access forbidden.'.format(storage.name) - elif storage_status == Status.NOT_FOUND: + elif storage_status == CloudStorageStatus.NOT_FOUND: msg = 'The resource {} not found. It may have been deleted.'.format(storage.name) else: msg = str(ex) return HttpResponseBadRequest(msg) - @swagger_auto_schema( - method='get', - operation_summary='Method returns a cloud storage status', + @extend_schema(summary='Method returns a cloud storage status', responses={ - '200': openapi.Response(description='Status'), - }, - tags=['cloud storages'] - ) + '200': OpenApiResponse(response=OpenApiTypes.STR, description='Cloud Storage status (AVAILABLE | NOT_FOUND | FORBIDDEN)'), + }, tags=['cloud storages'], versions=['2.0']) @action(detail=True, methods=['GET'], url_path='status') def status(self, request, pk): try: @@ -1420,15 +1612,6 @@ def rq_handler(job, exc_type, exc_value, tb): return True -# TODO: Method should be reimplemented as a separated view -# @swagger_auto_schema(method='put', manual_parameters=[openapi.Parameter('format', in_=openapi.IN_QUERY, -# description='A name of a loader\nYou can get annotation loaders from this API:\n/server/annotation/formats', -# required=True, type=openapi.TYPE_STRING)], -# operation_summary='Method allows to upload annotations', -# responses={'202': openapi.Response(description='Load of annotations has been started'), -# '201': openapi.Response(description='Annotations have been uploaded')}, -# tags=['tasks']) -# @api_view(['PUT']) def _import_annotations(request, rq_id, rq_func, pk, format_name): format_desc = {f.DISPLAY_NAME: f for f in dm.views.get_import_formats()}.get(format_name) diff --git a/cvat/apps/iam/__init__.py b/cvat/apps/iam/__init__.py index 813d16ca0ec..23dfb721d6d 100644 --- a/cvat/apps/iam/__init__.py +++ b/cvat/apps/iam/__init__.py @@ -2,3 +2,4 @@ # # SPDX-License-Identifier: MIT +from .schema import * \ No newline at end of file diff --git a/cvat/apps/iam/filters.py b/cvat/apps/iam/filters.py index 3a0539d5830..9b7d483efa5 100644 --- a/cvat/apps/iam/filters.py +++ b/cvat/apps/iam/filters.py @@ -6,12 +6,18 @@ from rest_framework.filters import BaseFilterBackend class OrganizationFilterBackend(BaseFilterBackend): + organization_slug = 'org' + organization_slug_description = 'Organization unique slug' + organization_id = 'org_id' + organization_id_description = 'Organization identifier' + def get_schema_fields(self, view): return [ - coreapi.Field(name='org', location='query', required=False, - type='string', description='Organization unique slug'), - coreapi.Field(name='org_id', location='query', required=False, - type='string', description='Organization identifier'), + # NOTE: in coreapi.Field 'type', 'description' and 'example' are now deprecated, in favor of 'schema'. + coreapi.Field(name=self.organization_slug, location='query', required=False, + type='string', description=self.organization_slug_description), + coreapi.Field(name=self.organization_id, location='query', required=False, + type='string', description=self.organization_id_description), ] def filter_queryset(self, request, queryset, view): diff --git a/cvat/apps/iam/schema.py b/cvat/apps/iam/schema.py new file mode 100644 index 00000000000..6cb7a3213f4 --- /dev/null +++ b/cvat/apps/iam/schema.py @@ -0,0 +1,44 @@ +# Copyright (C) 2022 Intel Corporation +# +# SPDX-License-Identifier: MIT + +from drf_spectacular.extensions import OpenApiFilterExtension, OpenApiAuthenticationExtension +from drf_spectacular.plumbing import build_parameter_type +from drf_spectacular.utils import OpenApiParameter + +# https://drf-spectacular.readthedocs.io/en/latest/customization.html?highlight=OpenApiFilterExtension#step-5-extensions +class OrganizationFilterExtension(OpenApiFilterExtension): + """Describe OrganizationFilterBackend filter""" + + target_class = 'cvat.apps.iam.filters.OrganizationFilterBackend' + priority = 1 + + def get_schema_operation_parameters(self, auto_schema, *args, **kwargs): + """Describe query parameters""" + return [ + build_parameter_type( + name=self.target.organization_slug, + required=False, + location=OpenApiParameter.QUERY, + description=self.target.organization_slug_description, + schema={'type': 'string'}, + ), + build_parameter_type( + name=self.target.organization_id, + required=False, + location=OpenApiParameter.QUERY, + description=self.target.organization_id_description, + schema={'type': 'string'}, + ) + ] + +class SignatureAuthenticationScheme(OpenApiAuthenticationExtension): + target_class = 'cvat.apps.iam.authentication.SignatureAuthentication' + name = 'SignatureAuthentication' # name used in the schema + + def get_security_definition(self, auto_schema): + return { + 'type': 'apiKey', + 'in': 'query', + 'name': 'sign', + } \ No newline at end of file diff --git a/cvat/apps/iam/views.py b/cvat/apps/iam/views.py index dd9b5974e24..9ebff150d1d 100644 --- a/cvat/apps/iam/views.py +++ b/cvat/apps/iam/views.py @@ -4,7 +4,7 @@ from django.core.exceptions import BadRequest from django.utils.functional import SimpleLazyObject -from rest_framework import views +from rest_framework import views, serializers from rest_framework.exceptions import ValidationError from django.conf import settings from rest_framework.response import Response @@ -12,9 +12,9 @@ from allauth.account import app_settings as allauth_settings from furl import furl -from django.utils.decorators import method_decorator -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer, extend_schema_view + from .authentication import Signer @@ -79,25 +79,19 @@ def __call__(self, request): return self.get_response(request) -@method_decorator(name='post', decorator=swagger_auto_schema( - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=[ - 'url' - ], - properties={ - 'url': openapi.Schema(type=openapi.TYPE_STRING) +@extend_schema_view(post=extend_schema( + summary='This method signs URL for access to the server', + description='Signed URL contains a token which authenticates a user on the server.' + 'Signed URL is valid during 30 seconds since signing.', + request=inline_serializer( + name='Signing', + fields={ + 'url': serializers.CharField(), } ), - responses={'200': openapi.Response(description='text URL')} -)) + responses={'200': OpenApiResponse(response=OpenApiTypes.STR, description='text URL')}, tags=['auth'], versions=['2.0'])) class SigningView(views.APIView): - """ - This method signs URL for access to the server. - Signed URL contains a token which authenticates a user on the server. - Signed URL is valid during 30 seconds since signing. - """ def post(self, request): url = request.data.get('url') if not url: diff --git a/cvat/apps/lambda_manager/urls.py b/cvat/apps/lambda_manager/urls.py index 198cbe70e44..7e7af7f1052 100644 --- a/cvat/apps/lambda_manager/urls.py +++ b/cvat/apps/lambda_manager/urls.py @@ -25,5 +25,5 @@ # GET /api/lambda/requests/ - get status of the request # DEL /api/lambda/requests/ - cancel a request (don't delete) urlpatterns = [ - path('api/lambda/', include((router.urls, 'cvat'), namespace='v1')) + path('api/lambda/', include(router.urls)) ] diff --git a/cvat/apps/lambda_manager/views.py b/cvat/apps/lambda_manager/views.py index 85bd0af4bab..18cc4db6064 100644 --- a/cvat/apps/lambda_manager/views.py +++ b/cvat/apps/lambda_manager/views.py @@ -17,6 +17,9 @@ from cvat.apps.engine.serializers import LabeledDataSerializer from cvat.apps.engine.models import ShapeType, SourceType +from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiResponse, OpenApiParameter +from drf_spectacular.types import OpenApiTypes + class LambdaType(Enum): DETECTOR = "detector" INTERACTOR = "interactor" @@ -228,7 +231,6 @@ def _get_image(self, db_task, frame, quality): return base64.b64encode(image[0].getvalue()).decode('utf-8') - class LambdaQueue: def _get_queue(self): QUEUE_NAME = "low" @@ -286,7 +288,6 @@ def fetch_job(self, pk): return LambdaJob(job) - class LambdaJob: def __init__(self, job): self.job = job @@ -552,10 +553,19 @@ def func_wrapper(*args, **kwargs): return func_wrapper return wrap_response +@extend_schema_view(retrieve=extend_schema( + summary='Method returns the information about the function', + responses={ + '200': OpenApiResponse(response=OpenApiTypes.OBJECT, description='Information about the function'), + }, + tags=['lambda'], versions=['2.0'])) +@extend_schema_view(list=extend_schema( + summary='Method returns a list of functions', tags=['lambda'], versions=['2.0'])) class FunctionViewSet(viewsets.ViewSet): lookup_value_regex = '[a-zA-Z0-9_.-]+' lookup_field = 'func_id' iam_organization_field = None + serializer_class = None @return_response() def list(self, request): @@ -585,8 +595,24 @@ def call(self, request, func_id): return lambda_func.invoke(db_task, request.data) +@extend_schema_view(retrieve=extend_schema( + summary='Method returns the status of the request', + parameters=[ + # specify correct type + OpenApiParameter('id', location=OpenApiParameter.PATH, type=OpenApiTypes.INT, + description='Request id'), + ], + tags=['lambda'], versions=['2.0'])) +@extend_schema_view(list=extend_schema( + summary='Method returns a list of requests', tags=['lambda'], versions=['2.0'])) +#TODO +@extend_schema_view(create=extend_schema( + summary='Method calls the function', tags=['lambda'], versions=['2.0'])) +@extend_schema_view(delete=extend_schema( + summary='Method cancels the request', tags=['lambda'], versions=['2.0'])) class RequestViewSet(viewsets.ViewSet): iam_organization_field = None + serializer_class = None @return_response() def list(self, request): diff --git a/cvat/apps/organizations/urls.py b/cvat/apps/organizations/urls.py index d0d0b23d222..fa05f0f76dd 100644 --- a/cvat/apps/organizations/urls.py +++ b/cvat/apps/organizations/urls.py @@ -10,4 +10,4 @@ router.register('invitations', InvitationViewSet) router.register('memberships', MembershipViewSet) -urlpatterns = router.urls \ No newline at end of file +urlpatterns = router.urls diff --git a/cvat/apps/organizations/views.py b/cvat/apps/organizations/views.py index ebd12053dce..d6def621cb4 100644 --- a/cvat/apps/organizations/views.py +++ b/cvat/apps/organizations/views.py @@ -7,6 +7,8 @@ from django.utils.crypto import get_random_string from django_filters import rest_framework as filters +from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view + from cvat.apps.iam.permissions import ( InvitationPermission, MembershipPermission, OrganizationPermission) from .models import Invitation, Membership, Organization @@ -16,6 +18,37 @@ MembershipReadSerializer, MembershipWriteSerializer, OrganizationReadSerializer, OrganizationWriteSerializer) + +@extend_schema_view(retrieve=extend_schema( + summary='Method returns details of an organization', + responses={ + '200': OrganizationReadSerializer, + }, tags=['organizations'], versions=['2.0'])) +@extend_schema_view(list=extend_schema( + summary='Method returns a paginated list of organizatins according to query parameters', + responses={ + '200': OrganizationReadSerializer(many=True), + }, tags=['organizations'], versions=['2.0'])) +@extend_schema_view(update=extend_schema( + summary='Method updates an organization by id', + responses={ + '200': OrganizationWriteSerializer, + }, tags=['organizations'], versions=['2.0'])) +@extend_schema_view(partial_update=extend_schema( + summary='Methods does a partial update of chosen fields in an organization', + responses={ + '200': OrganizationWriteSerializer, + }, tags=['organizations'], versions=['2.0'])) +@extend_schema_view(create=extend_schema( + summary='Method creates an organization', + responses={ + '201': OrganizationWriteSerializer, + }, tags=['organizations'], versions=['2.0'])) +@extend_schema_view(destroy=extend_schema( + summary='Method deletes an organization', + responses={ + '204': OpenApiResponse(description='The organization has been deleted'), + }, tags=['organizations'], versions=['2.0'])) class OrganizationViewSet(viewsets.ModelViewSet): queryset = Organization.objects.all() ordering = ['-id'] @@ -46,7 +79,31 @@ class MembershipFilter(filters.FilterSet): class Meta: model = Membership fields = ("user", ) - +@extend_schema_view(retrieve=extend_schema( + summary='Method returns details of a membership', + responses={ + '200': MembershipReadSerializer, + }, tags=['memberships'], versions=['2.0'])) +@extend_schema_view(list=extend_schema( + summary='Method returns a paginated list of memberships according to query parameters', + responses={ + '200': MembershipReadSerializer(many=True), + }, tags=['memberships'], versions=['2.0'])) +@extend_schema_view(update=extend_schema( + summary='Method updates a membership by id', + responses={ + '200': MembershipWriteSerializer, + }, tags=['memberships'], versions=['2.0'])) +@extend_schema_view(partial_update=extend_schema( + summary='Methods does a partial update of chosen fields in a membership', + responses={ + '200': MembershipWriteSerializer, + }, tags=['memberships'], versions=['2.0'])) +@extend_schema_view(destroy=extend_schema( + summary='Method deletes a membership', + responses={ + '204': OpenApiResponse(description='The membership has been deleted'), + }, tags=['memberships'], versions=['2.0'])) class MembershipViewSet(mixins.RetrieveModelMixin, mixins.DestroyModelMixin, mixins.ListModelMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet): queryset = Membership.objects.all() @@ -66,6 +123,37 @@ def get_queryset(self): permission = MembershipPermission(self.request, self) return permission.filter(queryset) +# TODO +@extend_schema_view(retrieve=extend_schema( + summary='Method returns details of an invitation', + responses={ + '200': InvitationReadSerializer, + }, tags=['invitations'], versions=['2.0'])) +@extend_schema_view(list=extend_schema( + summary='Method returns a paginated list of invitations according to query parameters', + responses={ + '200': InvitationReadSerializer(many=True), + }, tags=['invitations'], versions=['2.0'])) +@extend_schema_view(update=extend_schema( + summary='Method updates an invitation by id', + responses={ + '200': InvitationWriteSerializer, + }, tags=['invitations'], versions=['2.0'])) +@extend_schema_view(partial_update=extend_schema( + summary='Methods does a partial update of chosen fields in an invitation', + responses={ + '200': InvitationWriteSerializer, + }, tags=['invitations'], versions=['2.0'])) +@extend_schema_view(create=extend_schema( + summary='Method creates an invitation', + responses={ + '201': InvitationWriteSerializer, + }, tags=['invitations'], versions=['2.0'])) +@extend_schema_view(destroy=extend_schema( + summary='Method deletes an invitation', + responses={ + '204': OpenApiResponse(description='The invitation has been deleted'), + }, tags=['invitations'], versions=['2.0'])) class InvitationViewSet(viewsets.ModelViewSet): queryset = Invitation.objects.all() ordering = ['-created_date'] diff --git a/cvat/apps/restrictions/views.py b/cvat/apps/restrictions/views.py index 8b23e2ff97b..9b239f8c123 100644 --- a/cvat/apps/restrictions/views.py +++ b/cvat/apps/restrictions/views.py @@ -8,7 +8,8 @@ from rest_framework import viewsets from rest_framework.permissions import AllowAny from rest_framework.renderers import TemplateHTMLRenderer -from drf_yasg.utils import swagger_auto_schema +from drf_spectacular.utils import OpenApiResponse, extend_schema + from cvat.apps.restrictions.serializers import UserAgreementSerializer @@ -24,10 +25,9 @@ def get_serializer(self, *args, **kwargs): pass @staticmethod - @swagger_auto_schema( - method='get', - operation_summary='Method provides user agreements that the user must accept to register', - responses={'200': UserAgreementSerializer}) + @extend_schema(summary='Method provides user agreements that the user must accept to register', + responses={'200': UserAgreementSerializer}, + tags=['restrictions'], versions=['2.0']) @action(detail=False, methods=['GET'], serializer_class=UserAgreementSerializer, url_path='user-agreements') def user_agreements(request): user_agreements = settings.RESTRICTIONS['user_agreements'] @@ -36,6 +36,9 @@ def user_agreements(request): return Response(data=serializer.data) @staticmethod + @extend_schema(summary='Method provides CVAT terms of use', + responses={'200': OpenApiResponse(description='CVAT terms of use')}, + tags=['restrictions'], versions=['2.0']) @action(detail=False, methods=['GET'], renderer_classes=(TemplateHTMLRenderer,), url_path='terms-of-use') def terms_of_use(request): diff --git a/cvat/requirements/base.txt b/cvat/requirements/base.txt index d1c94d8dca0..43330ea486d 100644 --- a/cvat/requirements/base.txt +++ b/cvat/requirements/base.txt @@ -28,7 +28,7 @@ django-filter==2.4.0 Markdown==3.2.2 djangorestframework==3.12.4 Pygments==2.7.4 -drf-yasg==1.20.0 +drf-spectacular==0.21.2 Shapely==1.7.1 pdf2image==1.14.0 django-rest-auth[with_social]==0.9.5 diff --git a/cvat/settings/base.py b/cvat/settings/base.py index c8e7500c527..1f104746316 100644 --- a/cvat/settings/base.py +++ b/cvat/settings/base.py @@ -112,7 +112,7 @@ def add_ssh_keys(): 'rest_framework', 'rest_framework.authtoken', 'django_filters', - 'drf_yasg', + 'drf_spectacular', 'rest_auth', 'django.contrib.sites', 'allauth', @@ -177,6 +177,7 @@ def add_ssh_keys(): 'anon': '100/minute', }, 'DEFAULT_METADATA_CLASS': 'rest_framework.metadata.SimpleMetadata', + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', } REST_AUTH_REGISTER_SERIALIZERS = { @@ -492,7 +493,44 @@ def add_ssh_keys(): # How django uses X-Forwarded-Proto - https://docs.djangoproject.com/en/2.2/ref/settings/#secure-proxy-ssl-header SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') - # Django-sendfile requires to set SENDFILE_ROOT # https://github.com/moggers87/django-sendfile2 SENDFILE_ROOT = BASE_DIR + +SPECTACULAR_SETTINGS = { + 'TITLE': 'CVAT REST API', + 'DESCRIPTION': 'REST API for Computer Vision Annotation Tool (CVAT)', + # Statically set schema version. May also be an empty string. When used together with + # view versioning, will become '0.0.0 (v2)' for 'v2' versioned requests. + # Set VERSION to None if only the request version should be rendered. + 'VERSION': None, + 'CONTACT': { + 'name': 'Nikita Manovich', + 'url': 'https://github.com/nmanovic', + 'email': 'nikita.manovich@intel.com', + }, + 'LICENSE': { + 'name': 'MIT License', + 'url': 'https://en.wikipedia.org/wiki/MIT_License', + }, + 'SERVE_PUBLIC': True, + 'SCHEMA_COERCE_PATH_PK_SUFFIX': True, + 'SCHEMA_PATH_PREFIX': '/api', + 'SCHEMA_PATH_PREFIX_TRIM': False, + 'SERVE_PERMISSIONS': ['rest_framework.permissions.IsAuthenticated'], + # https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/ + 'SWAGGER_UI_SETTINGS': { + 'deepLinking': True, + 'displayOperationId': True, + 'displayRequestDuration': True, + 'filter': True, + 'showExtensions': True, + }, + 'TOS': 'https://www.google.com/policies/terms/', + 'EXTERNAL_DOCS': { + 'description': 'CVAT documentation', + 'url': 'https://openvinotoolkit.github.io/cvat/docs/', + }, + # OTHER SETTINGS + # https://drf-spectacular.readthedocs.io/en/latest/settings.html +}