diff --git a/label_studio/data_manager/serializers.py b/label_studio/data_manager/serializers.py index 71b9964c27e..87f5a410351 100644 --- a/label_studio/data_manager/serializers.py +++ b/label_studio/data_manager/serializers.py @@ -6,10 +6,17 @@ from data_manager.models import Filter, FilterGroup, View from django.conf import settings from django.db import transaction +from drf_yasg import openapi from projects.models import Project from rest_framework import serializers from tasks.models import Task -from tasks.serializers import AnnotationDraftSerializer, AnnotationSerializer, PredictionSerializer, TaskSerializer +from tasks.serializers import ( + AnnotationDraftSerializer, + AnnotationSerializer, + PredictionSerializer, + TaskSerializer, +) +from users.models import User from label_studio.core.utils.common import round_floats @@ -202,11 +209,137 @@ def update(self, instance, validated_data): return instance +class UpdatedByDMFieldSerializer(serializers.SerializerMethodField): + # TODO: get_updated_by implementation is weird, but we need to adhere schema to it + class Meta: + swagger_schema_fields = { + 'type': openapi.TYPE_ARRAY, + 'title': 'User IDs', + 'description': 'User IDs who updated this task', + 'items': {'type': openapi.TYPE_OBJECT, 'title': 'User IDs'}, + } + + +class AnnotatorsDMFieldSerializer(serializers.SerializerMethodField): + # TODO: get_updated_by implementation is weird, but we need to adhere schema to it + class Meta: + swagger_schema_fields = { + 'type': openapi.TYPE_ARRAY, + 'title': 'Annotators IDs', + 'description': 'Annotators IDs who annotated this task', + 'items': {'type': openapi.TYPE_INTEGER, 'title': 'User IDs'}, + } + + +class CompletedByDMSerializerWithGenericSchema(serializers.PrimaryKeyRelatedField): + # TODO: likely we need to remove full user details from GET /api/tasks/{id} as it non-secure and currently controlled by the export toggle + class Meta: + swagger_schema_fields = { + 'type': openapi.TYPE_OBJECT, + 'title': 'User details', + 'description': 'User details who completed this annotation.', + } + + +class AnnotationsDMFieldSerializer(AnnotationSerializer): + completed_by = CompletedByDMSerializerWithGenericSchema(required=False, queryset=User.objects.all()) + + +class AnnotationDraftDMFieldSerializer(serializers.SerializerMethodField): + class Meta: + swagger_schema_fields = { + 'type': openapi.TYPE_ARRAY, + 'title': 'Annotation drafts', + 'description': 'Drafts for this task', + 'items': { + 'type': openapi.TYPE_OBJECT, + 'title': 'Draft object', + 'properties': { + 'result': { + 'type': openapi.TYPE_ARRAY, + 'title': 'Draft result', + 'items': { + 'type': openapi.TYPE_OBJECT, + 'title': 'Draft result item', + }, + }, + 'created_at': { + 'type': openapi.TYPE_STRING, + 'format': 'date-time', + 'title': 'Creation time', + }, + 'updated_at': { + 'type': openapi.TYPE_STRING, + 'format': 'date-time', + 'title': 'Last update time', + }, + }, + }, + } + + +class PredictionsDMFieldSerializer(serializers.SerializerMethodField): + class Meta: + swagger_schema_fields = { + 'type': openapi.TYPE_ARRAY, + 'title': 'Predictions', + 'description': 'Predictions for this task', + 'items': { + 'type': openapi.TYPE_OBJECT, + 'title': 'Prediction object', + 'properties': { + 'result': { + 'type': openapi.TYPE_ARRAY, + 'title': 'Prediction result', + 'items': { + 'type': openapi.TYPE_OBJECT, + 'title': 'Prediction result item', + }, + }, + 'score': { + 'type': openapi.TYPE_NUMBER, + 'title': 'Prediction score', + }, + 'model_version': { + 'type': openapi.TYPE_STRING, + 'title': 'Model version', + }, + 'model': { + 'type': openapi.TYPE_OBJECT, + 'title': 'ML Backend instance', + }, + 'model_run': { + 'type': openapi.TYPE_OBJECT, + 'title': 'Model Run instance', + }, + 'task': { + 'type': openapi.TYPE_INTEGER, + 'title': 'Task ID related to the prediction', + }, + 'project': { + 'type': openapi.TYPE_NUMBER, + 'title': 'Project ID related to the prediction', + }, + 'created_at': { + 'type': openapi.TYPE_STRING, + 'format': 'date-time', + 'title': 'Creation time', + }, + 'updated_at': { + 'type': openapi.TYPE_STRING, + 'format': 'date-time', + 'title': 'Last update time', + }, + }, + }, + } + + class DataManagerTaskSerializer(TaskSerializer): - predictions = serializers.SerializerMethodField(required=False, read_only=True) - annotations = AnnotationSerializer(required=False, many=True, default=[], read_only=True) - drafts = serializers.SerializerMethodField(required=False, read_only=True) - annotators = serializers.SerializerMethodField(required=False, read_only=True) + predictions = PredictionsDMFieldSerializer(required=False, read_only=True) + annotations = AnnotationsDMFieldSerializer(required=False, many=True, default=[], read_only=True) + drafts = AnnotationDraftDMFieldSerializer(required=False, read_only=True) + annotators = AnnotatorsDMFieldSerializer(required=False, read_only=True) inner_id = serializers.IntegerField(required=False) cancelled_annotations = serializers.IntegerField(required=False) @@ -222,7 +355,7 @@ class DataManagerTaskSerializer(TaskSerializer): predictions_model_versions = serializers.SerializerMethodField(required=False) avg_lead_time = serializers.FloatField(required=False) draft_exists = serializers.BooleanField(required=False) - updated_by = serializers.SerializerMethodField(required=False, read_only=True) + updated_by = UpdatedByDMFieldSerializer(required=False, read_only=True) CHAR_LIMITS = 500 diff --git a/label_studio/tasks/api.py b/label_studio/tasks/api.py index 274ff2e3369..f50a4460a51 100644 --- a/label_studio/tasks/api.py +++ b/label_studio/tasks/api.py @@ -28,6 +28,7 @@ from tasks.openapi_schema import ( annotation_request_schema, annotation_response_example, + dm_task_response_example, prediction_request_schema, prediction_response_example, task_request_schema, @@ -208,7 +209,9 @@ def perform_create(self, serializer): request_body=no_body, responses={ '200': openapi.Response( - description='Task', schema=TaskSerializer, examples={'application/json': task_response_example} + description='Task', + schema=DataManagerTaskSerializer, + examples={'application/json': dm_task_response_example}, ) }, ), diff --git a/label_studio/tasks/openapi_schema.py b/label_studio/tasks/openapi_schema.py index b6c12e6c3b2..3393aabd48b 100644 --- a/label_studio/tasks/openapi_schema.py +++ b/label_studio/tasks/openapi_schema.py @@ -23,8 +23,8 @@ 'id': 1, 'data': {'image': 'https://example.com/image.jpg', 'text': 'Hello, AI!'}, 'project': 1, - 'created_at': '2024-01-15T09:30:00Z', - 'updated_at': '2024-01-15T09:30:00Z', + 'created_at': '2024-06-18T23:45:46.048490Z', + 'updated_at': '2024-06-18T23:45:46.048538Z', 'is_labeled': False, 'overlap': 1, 'inner_id': 1, @@ -34,11 +34,45 @@ 'comment_count': 0, 'unresolved_comment_count': 0, 'last_comment_updated_at': '2024-01-15T09:30:00Z', - 'updated_by': 1, - 'file_upload': 1, + 'updated_by': [{'user_id': 1}], + 'file_upload': '42d46c4c-my-pic.jpeg', 'comment_authors': [1], } +dm_task_response_example = { + 'id': 13, + 'predictions': [], + 'annotations': [], + 'drafts': [], + 'annotators': [], + 'inner_id': 2, + 'cancelled_annotations': 0, + 'total_annotations': 0, + 'total_predictions': 0, + 'completed_at': None, + 'annotations_results': '', + 'predictions_results': '', + 'predictions_score': None, + 'file_upload': '6b25fc23-some_3.mp4', + 'storage_filename': None, + 'annotations_ids': '', + 'predictions_model_versions': '', + 'avg_lead_time': None, + 'draft_exists': False, + 'updated_by': [], + 'data': {'image': '/data/upload/1/6b25fc23-some_3.mp4'}, + 'meta': {}, + 'created_at': '2024-06-18T23:45:46.048490Z', + 'updated_at': '2024-06-18T23:45:46.048538Z', + 'is_labeled': False, + 'overlap': 1, + 'comment_count': 0, + 'unresolved_comment_count': 0, + 'last_comment_updated_at': None, + 'project': 1, + 'comment_authors': [], +} + annotation_response_example = { 'id': 1, 'result': result_example, diff --git a/label_studio/tests/sdk/test_tasks.py b/label_studio/tests/sdk/test_tasks.py index bd22f3c3bda..824b3642992 100644 --- a/label_studio/tests/sdk/test_tasks.py +++ b/label_studio/tests/sdk/test_tasks.py @@ -52,17 +52,19 @@ def test_delete_multi_tasks(django_live_url, business_client): ls.actions.create(project=p.id, id='delete_tasks', selected_items={'all': False, 'included': tasks_ids_to_delete}) assert len([task for task in ls.tasks.list(project=p.id)]) == 5 - ls.actions.create(project=p.id, id='delete_tasks', selected_items={'all': True, 'excluded': [tasks[5].id]}) - # another way of calling delete action - # ls.actions.create(request_options={ - # 'additional_query_parameters': { - # 'project': p.id, - # 'id': 'delete_tasks' - # }, - # 'additional_body_parameters': { - # 'selectedItems': {"all": True, "excluded": [tasks[5].id]}, - # } - # }) + # another way of calling delete action instead of + # ls.actions.create(project=p.id, id='delete_tasks', selected_items={'all': True, 'excluded': [tasks[5].id]}) + import json + + ls.actions.create( + project=p.id, + id='delete_tasks', + request_options={ + 'additional_body_parameters': { + 'selectedItems': json.dumps({'all': True, 'excluded': [tasks[5].id]}), + }, + }, + ) remaining_tasks = [task for task in ls.tasks.list(project=p.id)] assert len(remaining_tasks) == 1 @@ -94,7 +96,12 @@ def test_export_tasks(django_live_url, business_client): } ls.annotations.create(id=task_id, **annotation_data) - # by default, only tasks with annotations are exported + # export a singleton task + single_task = ls.tasks.get(id=task_id) + assert single_task.data['my_text'] == 'Test task 7' + assert single_task.total_annotations == 1 + assert single_task.updated_by == [{'user_id': business_client.user.id}] + exported_tasks = [task for task in ls.tasks.list(project=p.id, fields='all') if task.annotations] assert len(exported_tasks) == 1 assert exported_tasks[0].data['my_text'] == 'Test task 7'