diff --git a/cvat/apps/authentication/views.py b/cvat/apps/authentication/views.py index 1e1bbb1004b1..b2497e2aba68 100644 --- a/cvat/apps/authentication/views.py +++ b/cvat/apps/authentication/views.py @@ -13,6 +13,10 @@ from . import forms from . import signature +from django.utils.decorators import method_decorator +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi + def register_user(request): if request.method == 'POST': form = forms.NewUserForm(request.POST) @@ -27,7 +31,25 @@ def register_user(request): form = forms.NewUserForm() return render(request, 'register.html', {'form': form}) +@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) + } + ), + responses={'200': openapi.Response(description='text URL')} +)) 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/engine/views.py b/cvat/apps/engine/views.py index 691951538d47..405910cbc9db 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -40,7 +40,7 @@ RqStatusSerializer, TaskDataSerializer, LabeledDataSerializer, PluginSerializer, FileInfoSerializer, LogEventSerializer, ProjectSerializer, BasicUserSerializer) -from cvat.apps.annotation.serializers import AnnotationFileSerializer +from cvat.apps.annotation.serializers import AnnotationFileSerializer, AnnotationFormatSerializer from django.contrib.auth.models import User from django.core.exceptions import ObjectDoesNotExist from cvat.apps.authentication import auth @@ -49,6 +49,12 @@ from cvat.apps.annotation.format import get_annotation_formats import cvat.apps.dataset_manager.task as DatumaroTask +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi +from django.utils.decorators import method_decorator +from drf_yasg.inspectors import NotHandled, CoreAPICompatInspector +from django_filters.rest_framework import DjangoFilterBackend + # Server REST API @login_required def dispatch_request(request): @@ -71,6 +77,8 @@ def get_serializer(self, *args, **kwargs): pass @staticmethod + @swagger_auto_schema(method='get', operation_summary='Method provides basic CVAT information', + responses={'200': AboutSerializer}) @action(detail=False, methods=['GET'], serializer_class=AboutSerializer) def about(request): from cvat import __version__ as cvat_version @@ -90,8 +98,14 @@ def about(request): return Response(data=serializer.data) @staticmethod + @swagger_auto_schema(method='post', request_body=ExceptionSerializer) @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 = { @@ -111,8 +125,14 @@ def exception(request): return Response(serializer.data, status=status.HTTP_201_CREATED) @staticmethod + @swagger_auto_schema(method='post', request_body=LogEventSerializer(many=True)) @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 } @@ -129,6 +149,11 @@ 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)} + ) @action(detail=False, methods=['GET'], serializer_class=FileInfoSerializer) def share(request): param = request.query_params.get('directory', '/') @@ -157,6 +182,8 @@ def share(request): status=status.HTTP_400_BAD_REQUEST) @staticmethod + @swagger_auto_schema(method='get', operation_summary='Method provides the list of available annotations formats supported by the server', + responses={'200': AnnotationFormatSerializer(many=True)}) @action(detail=False, methods=['GET'], url_path='annotation/formats') def formats(request): data = get_annotation_formats() @@ -172,6 +199,23 @@ class Meta: model = models.Project fields = ("id", "name", "owner", "status", "assignee") +@method_decorator(name='list', decorator=swagger_auto_schema( + operation_summary='Returns a paginated list of projects according to query parameters (10 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('assignee', openapi.IN_QUERY, description="Find all projects where assignee name contains a parameter value", + type=openapi.TYPE_STRING)])) +@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')) class ProjectViewSet(auth.ProjectGetQuerySetMixin, viewsets.ModelViewSet): queryset = models.Project.objects.all().order_by('-id') serializer_class = ProjectSerializer @@ -203,6 +247,8 @@ def perform_create(self, serializer): else: serializer.save(owner=self.request.user) + @swagger_auto_schema(method='get', operation_summary='Returns information of the tasks of the project with the selected id', + responses={'200': TaskSerializer(many=True)}) @action(detail=True, methods=['GET'], serializer_class=TaskSerializer) def tasks(self, request, pk): self.get_object() # force to call check_object_permissions @@ -232,6 +278,35 @@ 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')) class TaskViewSet(auth.TaskGetQuerySetMixin, viewsets.ModelViewSet): queryset = Task.objects.all().prefetch_related( "label_set__attributespec_set", @@ -270,6 +345,8 @@ def perform_destroy(self, instance): super().perform_destroy(instance) shutil.rmtree(task_dirname, ignore_errors=True) + @swagger_auto_schema(method='get', operation_summary='Returns a list of jobs for a specific task', + responses={'200': JobSerializer(many=True)}) @action(detail=True, methods=['GET'], serializer_class=JobSerializer) def jobs(self, request, pk): self.get_object() # force to call check_object_permissions @@ -279,8 +356,12 @@ def jobs(self, request, pk): return Response(serializer.data) + @swagger_auto_schema(method='post', operation_summary='Method permanently attaches images or video to a task') @action(detail=True, methods=['POST'], serializer_class=TaskDataSerializer) def data(self, request, pk): + """ + These data cannot be changed later + """ db_task = self.get_object() # call check_object_permissions as well serializer = TaskDataSerializer(db_task, data=request.data) if serializer.is_valid(raise_exception=True): @@ -288,6 +369,12 @@ def data(self, request, pk): task.create(db_task.id, serializer.data) return Response(serializer.data, status=status.HTTP_202_ACCEPTED) + @swagger_auto_schema(method='get', operation_summary='Method returns annotations for a specific task') + @swagger_auto_schema(method='put', operation_summary='Method performs an update of all annotations in a specific task') + @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') @action(detail=True, methods=['GET', 'DELETE', 'PUT', 'PATCH'], serializer_class=LabeledDataSerializer) def annotations(self, request, pk): @@ -326,9 +413,23 @@ 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 allows to download annotations as a file', + manual_parameters=[openapi.Parameter('filename', openapi.IN_PATH, description="A name of a file with annotations", + type=openapi.TYPE_STRING, required=True), + openapi.Parameter('format', openapi.IN_QUERY, description="A name of a dumper\nYou can get annotation dumpers from this API:\n/server/annotation/formats", + type=openapi.TYPE_STRING, required=True), + openapi.Parameter('action', in_=openapi.IN_QUERY, description='Used to start downloading process after annotation file had been created', + required=False, enum=['download'], type=openapi.TYPE_STRING)], + 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')}) @action(detail=True, methods=['GET'], serializer_class=None, url_path='annotations/(?P[^/]+)') def dump(self, request, pk, filename): + """ + Dump of annotations in common case is a long process which cannot be performed within one request. + First request starts dumping process. When the file is ready (code 201) you can get it with query parameter action=download. + """ filename = re.sub(r'[\\/*?:"<>|]', '_', filename) username = request.user.username db_task = self.get_object() # call check_object_permissions as well @@ -387,6 +488,7 @@ def dump(self, request, pk, filename): return Response(status=status.HTTP_202_ACCEPTED) + @swagger_auto_schema(method='get', operation_summary='When task is being created the method returns information about a status of the creation process') @action(detail=True, methods=['GET'], serializer_class=RqStatusSerializer) def status(self, request, pk): self.get_object() # force to call check_object_permissions @@ -415,6 +517,8 @@ def _get_rq_response(queue, job_id): return response + @swagger_auto_schema(method='get', operation_summary='Method provides a list of sizes (width, height) of media files which are related with the task', + responses={'200': ImageMetaSerializer(many=True)}) @action(detail=True, methods=['GET'], serializer_class=ImageMetaSerializer, url_path='frames/meta') def data_info(self, request, pk): @@ -430,11 +534,13 @@ def data_info(self, request, pk): if serializer.is_valid(raise_exception=True): return Response(serializer.data) + @swagger_auto_schema(method='get', manual_parameters=[openapi.Parameter('frame', openapi.IN_PATH, required=True, + description="A unique integer value identifying this frame", type=openapi.TYPE_INTEGER)], + operation_summary='Method returns a specific frame for a specific task', + responses={'200': openapi.Response(description='frame')}) @action(detail=True, methods=['GET'], serializer_class=None, url_path='frames/(?P\d+)') def frame(self, request, pk, frame): - """Get a frame for the task""" - try: # Follow symbol links if the frame is a link on a real image otherwise # mimetype detection inside sendfile will work incorrectly. @@ -446,10 +552,16 @@ def frame(self, request, pk, frame): "cannot get frame #{}".format(frame), exc_info=True) return HttpResponseBadRequest(str(e)) + @swagger_auto_schema(method='get', operation_summary='Export task as a dataset in a specific format', + manual_parameters=[openapi.Parameter('action', in_=openapi.IN_QUERY, + required=False, type=openapi.TYPE_STRING, enum=['download']), + openapi.Parameter('format', in_=openapi.IN_QUERY, required=False, type=openapi.TYPE_STRING)], + 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')}) @action(detail=True, methods=['GET'], serializer_class=None, url_path='export/') def dataset_export(self, request, pk): - """Export task as a dataset in a specific format""" db_task = self.get_object() @@ -506,6 +618,10 @@ def dataset_export(self, request, pk): result_ttl=ttl, failure_ttl=ttl) return Response(status=status.HTTP_201_CREATED) +@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')) class JobViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin, mixins.UpdateModelMixin): queryset = Job.objects.all().order_by('id') @@ -524,7 +640,13 @@ def get_permissions(self): return [perm() for perm in permissions] - + @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') @action(detail=True, methods=['GET', 'DELETE', 'PUT', 'PATCH'], serializer_class=LabeledDataSerializer) def annotations(self, request, pk): @@ -565,6 +687,14 @@ def annotations(self, request, pk): return Response(data=str(e), status=status.HTTP_400_BAD_REQUEST) return Response(data) +@method_decorator(name='list', decorator=swagger_auto_schema( + 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')) class UserViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, mixins.RetrieveModelMixin, mixins.UpdateModelMixin, mixins.DestroyModelMixin): queryset = User.objects.all().order_by('id') @@ -593,8 +723,12 @@ def get_permissions(self): return [perm() for perm in permissions] + @swagger_auto_schema(method='get', operation_summary='Method returns an instance of a user who is currently authorized') @action(detail=False, methods=['GET']) def self(self, request): + """ + Method returns an instance of a user who is currently authorized + """ serializer_class = self.get_serializer_class() serializer = serializer_class(request.user, context={ "request": request }) return Response(serializer.data) @@ -635,6 +769,15 @@ 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 load_data_proxy(request, rq_id, rq_func, pk): queue = django_rq.get_queue("default") rq_job = queue.fetch_job(rq_id)