diff --git a/app/api/migrations/0002_project_single_class_classification.py b/app/api/migrations/0002_project_single_class_classification.py new file mode 100644 index 00000000..fbab8096 --- /dev/null +++ b/app/api/migrations/0002_project_single_class_classification.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.11 on 2019-12-06 08:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='single_class_classification', + field=models.BooleanField(default=False), + ), + ] diff --git a/app/api/models.py b/app/api/models.py index aa2cf6f9..17ea3d2e 100644 --- a/app/api/models.py +++ b/app/api/models.py @@ -34,6 +34,7 @@ class Project(PolymorphicModel): project_type = models.CharField(max_length=30, choices=PROJECT_CHOICES) randomize_document_order = models.BooleanField(default=False) collaborative_annotation = models.BooleanField(default=False) + single_class_classification = models.BooleanField(default=False) def get_absolute_url(self): return reverse('upload', args=[self.id]) diff --git a/app/api/serializers.py b/app/api/serializers.py index 360f3bb9..58c01ed1 100644 --- a/app/api/serializers.py +++ b/app/api/serializers.py @@ -105,7 +105,7 @@ class ProjectSerializer(serializers.ModelSerializer): class Meta: model = Project fields = ('id', 'name', 'description', 'guideline', 'users', 'current_users_role', 'project_type', 'image', - 'updated_at', 'randomize_document_order', 'collaborative_annotation') + 'updated_at', 'randomize_document_order', 'collaborative_annotation', 'single_class_classification') read_only_fields = ('image', 'updated_at', 'users', 'current_users_role') diff --git a/app/api/tests/test_api.py b/app/api/tests/test_api.py index 94716476..a1387a15 100644 --- a/app/api/tests/test_api.py +++ b/app/api/tests/test_api.py @@ -744,6 +744,18 @@ class TestAnnotationListAPI(APITestCase, TestUtilsMixin): sub_project_doc = mommy.make('Document', project=sub_project) mommy.make('SequenceAnnotation', document=sub_project_doc) + cls.classification_project = mommy.make('TextClassificationProject', + users=[project_member, another_project_member]) + cls.classification_project_label_1 = mommy.make('Label', project=cls.classification_project) + cls.classification_project_label_2 = mommy.make('Label', project=cls.classification_project) + cls.classification_project_document = mommy.make('Document', project=cls.classification_project) + cls.classification_project_url = reverse( + viewname='annotation_list', args=[cls.classification_project.id, cls.classification_project_document.id]) + assign_user_to_role(project_member=project_member, project=cls.classification_project, + role_name=settings.ROLE_ANNOTATOR) + assign_user_to_role(project_member=another_project_member, project=cls.classification_project, + role_name=settings.ROLE_ANNOTATOR) + cls.url = reverse(viewname='annotation_list', args=[main_project.id, main_project_doc.id]) cls.post_data = {'start_offset': 0, 'end_offset': 1, 'label': main_project_label.id} cls.num_entity_of_project_member = SequenceAnnotation.objects.filter(document=main_project_doc, @@ -794,6 +806,32 @@ class TestAnnotationListAPI(APITestCase, TestUtilsMixin): response = self.client.post(self.url, format='json', data=self.post_data) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + def test_disallows_second_annotation_for_single_class_project(self): + self._patch_project(self.classification_project, 'single_class_classification', True) + + self.client.login(username=self.project_member_name, password=self.project_member_pass) + response = self.client.post(self.classification_project_url, format='json', + data={'label': self.classification_project_label_1.id}) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + response = self.client.post(self.classification_project_url, format='json', + data={'label': self.classification_project_label_2.id}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_disallows_second_annotation_for_single_class_shared_project(self): + self._patch_project(self.classification_project, 'single_class_classification', True) + self._patch_project(self.classification_project, 'collaborative_annotation', True) + + self.client.login(username=self.project_member_name, password=self.project_member_pass) + response = self.client.post(self.classification_project_url, format='json', + data={'label': self.classification_project_label_1.id}) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + self.client.login(username=self.another_project_member_name, password=self.another_project_member_pass) + response = self.client.post(self.classification_project_url, format='json', + data={'label': self.classification_project_label_2.id}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + def _patch_project(self, project, attribute, value): old_value = getattr(project, attribute, None) setattr(project, attribute, value) diff --git a/app/api/views.py b/app/api/views.py index 87c8d4a2..5635c984 100644 --- a/app/api/views.py +++ b/app/api/views.py @@ -210,12 +210,28 @@ class AnnotationList(generics.ListCreateAPIView): return queryset def create(self, request, *args, **kwargs): + self.check_single_class_classification(self.kwargs['project_id'], self.kwargs['doc_id'], request.user) + request.data['document'] = self.kwargs['doc_id'] return super().create(request, args, kwargs) def perform_create(self, serializer): serializer.save(document_id=self.kwargs['doc_id'], user=self.request.user) + @staticmethod + def check_single_class_classification(project_id, doc_id, user): + project = get_object_or_404(Project, pk=project_id) + if not project.single_class_classification: + return + + model = project.get_annotation_class() + annotations = model.objects.filter(document_id=doc_id) + if not project.collaborative_annotation: + annotations = annotations.filter(user=user) + + if annotations.exists(): + raise ValidationError('requested to create duplicate annotation for single-class-classification project') + class AnnotationDetail(generics.RetrieveUpdateDestroyAPIView): lookup_url_kwarg = 'annotation_id' diff --git a/app/server/static/components/annotationMixin.js b/app/server/static/components/annotationMixin.js index 7569f3c1..6dbd9cb1 100644 --- a/app/server/static/components/annotationMixin.js +++ b/app/server/static/components/annotationMixin.js @@ -92,6 +92,7 @@ export default { prevLimit: 0, paginationPages: 0, paginationPage: 0, + singleClassClassification: false, isAnnotationApprover: false, isMetadataActive: false, isAnnotationGuidelineActive: false, @@ -210,7 +211,7 @@ export default { removeLabel(annotation) { const docId = this.docs[this.pageNumber].id; - HTTP.delete(`docs/${docId}/annotations/${annotation.id}`).then(() => { + return HTTP.delete(`docs/${docId}/annotations/${annotation.id}`).then(() => { const index = this.annotations[this.pageNumber].indexOf(annotation); this.annotations[this.pageNumber].splice(index, 1); }); @@ -272,6 +273,7 @@ export default { this.labels = response.data; }); HTTP.get().then((response) => { + this.singleClassClassification = response.data.single_class_classification; this.guideline = response.data.guideline; const roles = response.data.current_users_role; this.isAnnotationApprover = roles.is_annotation_approver || roles.is_project_admin; diff --git a/app/server/static/components/document_classification.vue b/app/server/static/components/document_classification.vue index 9f91311a..d3a24b51 100644 --- a/app/server/static/components/document_classification.vue +++ b/app/server/static/components/document_classification.vue @@ -58,10 +58,6 @@ export default { mixins: [annotationMixin], methods: { - getAnnotation(label) { - return this.annotations[this.pageNumber].find(annotation => annotation.label === label.id); - }, - async submit() { const state = this.getState(); this.url = `docs?q=${this.searchQuery}&doc_annotations__isnull=${state}&offset=${this.offset}&ordering=${this.ordering}`; @@ -70,10 +66,16 @@ export default { }, async addLabel(label) { - const annotation = this.getAnnotation(label); + const annotations = this.annotations[this.pageNumber]; + const annotation = annotations.find(item => item.label === label.id); + if (annotation) { this.removeLabel(annotation); } else { + if (this.singleClassClassification && annotations.length >= 1) { + await Promise.all(annotations.map(item => this.removeLabel(item))); + } + const docId = this.docs[this.pageNumber].id; const payload = { label: label.id, diff --git a/app/server/static/components/projects.vue b/app/server/static/components/projects.vue index 7e69f2f7..6dccfe58 100644 --- a/app/server/static/components/projects.vue +++ b/app/server/static/components/projects.vue @@ -65,6 +65,17 @@ ) | Share annotations across all users + div.field(v-if="projectType === 'DocumentClassification'") + label.checkbox + input( + v-model="singleClassClassification" + name="single_class_classification" + type="checkbox" + style="margin-right: 0.25em;" + required + ) + | Single-class classification + footer.modal-card-foot.pt20.pb20.pr20.pl20.has-background-white-ter button.button.is-primary(v-on:click="create()") Create button.button(v-on:click="isActive = !isActive") Cancel @@ -152,6 +163,7 @@ export default { projectNameError: '', username: '', isSuperuser: false, + singleClassClassification: false, randomizeDocumentOrder: false, collaborativeAnnotation: false, isProjectAdmin: null, @@ -213,6 +225,7 @@ export default { name: this.projectName, description: this.description, project_type: this.projectType, + single_class_classification: this.singleClassClassification, randomize_document_order: this.randomizeDocumentOrder, collaborative_annotation: this.collaborativeAnnotation, guideline: 'Please write annotation guideline.',