diff --git a/backend/api/tests/api/test_annotation.py b/backend/api/tests/api/test_annotation.py index 242f88c5..ac924faf 100644 --- a/backend/api/tests/api/test_annotation.py +++ b/backend/api/tests/api/test_annotation.py @@ -7,15 +7,17 @@ from .utils import (CRUDMixin, make_annotation, make_doc, make_label, class TestAnnotationList(CRUDMixin): + task = DOCUMENT_CLASSIFICATION + view_name = 'annotation_list' @classmethod def setUpTestData(cls): - cls.project = prepare_project(task=DOCUMENT_CLASSIFICATION) + cls.project = prepare_project(task=cls.task) cls.non_member = make_user() doc = make_doc(cls.project.item) for member in cls.project.users: - make_annotation(task=DOCUMENT_CLASSIFICATION, doc=doc, user=member) - cls.url = reverse(viewname='annotation_list', args=[cls.project.item.id, doc.id]) + make_annotation(task=cls.task, doc=doc, user=member) + cls.url = reverse(viewname=cls.view_name, args=[cls.project.item.id, doc.id]) def test_allows_project_member_to_fetch_annotation(self): for member in self.project.users: @@ -34,15 +36,21 @@ class TestAnnotationList(CRUDMixin): self.assertEqual(count, 2) # delete only own annotation +class TestCategoryList(TestAnnotationList): + view_name = 'category_list' + + class TestSharedAnnotationList(CRUDMixin): + task = DOCUMENT_CLASSIFICATION + view_name = 'annotation_list' @classmethod def setUpTestData(cls): - cls.project = prepare_project(task=DOCUMENT_CLASSIFICATION, collaborative_annotation=True) + cls.project = prepare_project(task=cls.task, collaborative_annotation=True) doc = make_doc(cls.project.item) for member in cls.project.users: - make_annotation(task=DOCUMENT_CLASSIFICATION, doc=doc, user=member) - cls.url = reverse(viewname='annotation_list', args=[cls.project.item.id, doc.id]) + make_annotation(task=cls.task, doc=doc, user=member) + cls.url = reverse(viewname=cls.view_name, args=[cls.project.item.id, doc.id]) def test_allows_project_member_to_fetch_all_annotation(self): for member in self.project.users: @@ -55,15 +63,21 @@ class TestSharedAnnotationList(CRUDMixin): self.assertEqual(count, 0) # delete all annotation in the doc +class TestSharedCategoryList(TestSharedAnnotationList): + view_name = 'category_list' + + class TestAnnotationCreation(CRUDMixin): + task = DOCUMENT_CLASSIFICATION + view_name = 'annotation_list' def setUp(self): - self.project = prepare_project(task=DOCUMENT_CLASSIFICATION) + self.project = prepare_project(task=self.task) self.non_member = make_user() doc = make_doc(self.project.item) label = make_label(self.project.item) self.data = {'label': label.id} - self.url = reverse(viewname='annotation_list', args=[self.project.item.id, doc.id]) + self.url = reverse(viewname=self.view_name, args=[self.project.item.id, doc.id]) def test_allows_project_member_to_annotate(self): for member in self.project.users: @@ -76,22 +90,31 @@ class TestAnnotationCreation(CRUDMixin): self.assert_create(expected=status.HTTP_403_FORBIDDEN) +class TestCategoryCreation(TestAnnotationCreation): + view_name = 'category_list' + + class TestAnnotationDetail(CRUDMixin): + task = SEQUENCE_LABELING + view_name = 'annotation_detail' def setUp(self): - self.project = prepare_project(task=SEQUENCE_LABELING) + self.project = prepare_project(task=self.task) self.non_member = make_user() doc = make_doc(self.project.item) label = make_label(self.project.item) - annotation = make_annotation( - task=SEQUENCE_LABELING, + annotation = self.create_annotation_data(doc=doc) + self.data = {'label': label.id} + self.url = reverse(viewname=self.view_name, args=[self.project.item.id, doc.id, annotation.id]) + + def create_annotation_data(self, doc): + return make_annotation( + task=self.task, doc=doc, user=self.project.users[0], start_offset=0, end_offset=1 ) - self.data = {'label': label.id} - self.url = reverse(viewname='annotation_detail', args=[self.project.item.id, doc.id, annotation.id]) def test_allows_owner_to_get_annotation(self): self.assert_fetch(self.project.users[0], status.HTTP_200_OK) @@ -127,15 +150,25 @@ class TestAnnotationDetail(CRUDMixin): self.assert_delete(self.non_member, status.HTTP_403_FORBIDDEN) +class TestCategoryDetail(TestAnnotationDetail): + task = DOCUMENT_CLASSIFICATION + view_name = 'category_detail' + + def create_annotation_data(self, doc): + return make_annotation(task=self.task, doc=doc, user=self.project.users[0]) + + class TestSharedAnnotationDetail(CRUDMixin): + task = DOCUMENT_CLASSIFICATION + view_name = 'annotation_detail' def setUp(self): - self.project = prepare_project(task=DOCUMENT_CLASSIFICATION, collaborative_annotation=True) + self.project = prepare_project(task=self.task, collaborative_annotation=True) doc = make_doc(self.project.item) - annotation = make_annotation(task=DOCUMENT_CLASSIFICATION, doc=doc, user=self.project.users[0]) + annotation = make_annotation(task=self.task, doc=doc, user=self.project.users[0]) label = make_label(self.project.item) self.data = {'label': label.id} - self.url = reverse(viewname='annotation_detail', args=[self.project.item.id, doc.id, annotation.id]) + self.url = reverse(viewname=self.view_name, args=[self.project.item.id, doc.id, annotation.id]) def test_allows_any_member_to_get_annotation(self): for member in self.project.users: @@ -147,3 +180,7 @@ class TestSharedAnnotationDetail(CRUDMixin): def test_allows_any_member_to_delete_annotation(self): self.assert_delete(self.project.users[1], status.HTTP_204_NO_CONTENT) + + +class TestSharedCategoryDetail(TestSharedAnnotationDetail): + view_name = 'category_detail' diff --git a/backend/api/urls.py b/backend/api/urls.py index 6493e657..32497403 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -4,6 +4,7 @@ from .views import (annotation, annotation_relations, auto_labeling, comment, example, example_state, export_dataset, health, import_dataset, import_export, label, project, relation_types, role, statistics, tag, task, user) +from .views.tasks import category urlpatterns_project = [ path( @@ -112,6 +113,16 @@ urlpatterns_project = [ view=annotation.AnnotationDetail.as_view(), name='annotation_detail' ), + path( + route='examples//categories', + view=category.CategoryListAPI.as_view(), + name='category_list' + ), + path( + route='examples//categories/', + view=category.CategoryDetailAPI.as_view(), + name='category_detail' + ), path( route='tags', view=tag.TagList.as_view(), diff --git a/backend/api/views/tasks/__init__.py b/backend/api/views/tasks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/views/tasks/base.py b/backend/api/views/tasks/base.py new file mode 100644 index 00000000..ebcf22ff --- /dev/null +++ b/backend/api/views/tasks/base.py @@ -0,0 +1,57 @@ +from django.core.exceptions import ValidationError +from django.shortcuts import get_object_or_404 +from rest_framework import generics, status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from ...models import Project +from ...permissions import IsInProjectOrAdmin, IsOwnAnnotation + + +class BaseListAPI(generics.ListCreateAPIView): + annotation_class = None + pagination_class = None + permission_classes = [IsAuthenticated & IsInProjectOrAdmin] + swagger_schema = None + + @property + def project(self): + return get_object_or_404(Project, pk=self.kwargs['project_id']) + + def get_queryset(self): + queryset = self.annotation_class.objects.filter(example=self.kwargs['example_id']) + if not self.project.collaborative_annotation: + queryset = queryset.filter(user=self.request.user) + return queryset + + def create(self, request, *args, **kwargs): + request.data['example'] = self.kwargs['example_id'] + try: + response = super().create(request, args, kwargs) + except ValidationError as err: + response = Response({'detail': err.messages}, status=status.HTTP_400_BAD_REQUEST) + return response + + def perform_create(self, serializer): + serializer.save(example_id=self.kwargs['example_id'], user=self.request.user) + + def delete(self, request, *args, **kwargs): + queryset = self.get_queryset() + queryset.all().delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class BaseDetailAPI(generics.RetrieveUpdateDestroyAPIView): + lookup_url_kwarg = 'annotation_id' + swagger_schema = None + + @property + def project(self): + return get_object_or_404(Project, pk=self.kwargs['project_id']) + + def get_permissions(self): + if self.project.collaborative_annotation: + self.permission_classes = [IsAuthenticated & IsInProjectOrAdmin] + else: + self.permission_classes = [IsAuthenticated & IsInProjectOrAdmin & IsOwnAnnotation] + return super().get_permissions() diff --git a/backend/api/views/tasks/category.py b/backend/api/views/tasks/category.py new file mode 100644 index 00000000..7f3eee5d --- /dev/null +++ b/backend/api/views/tasks/category.py @@ -0,0 +1,18 @@ +from ...models import Category +from ...serializers import CategorySerializer +from .base import BaseDetailAPI, BaseListAPI + + +class CategoryListAPI(BaseListAPI): + annotation_class = Category + serializer_class = CategorySerializer + + def create(self, request, *args, **kwargs): + if self.project.single_class_classification: + self.get_queryset().delete() + return super().create(request, args, kwargs) + + +class CategoryDetailAPI(BaseDetailAPI): + queryset = Category.objects.all() + serializer_class = CategorySerializer