Browse Source

Merge pull request #489 from CatalystCode/enhancement/single-class-classification

Enhancement/Single class classification
pull/837/head
Hiroki Nakayama 4 years ago
committed by GitHub
parent
commit
a6f0d0ecf7
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 97 additions and 7 deletions
  1. 18
      app/api/migrations/0002_project_single_class_classification.py
  2. 1
      app/api/models.py
  3. 2
      app/api/serializers.py
  4. 38
      app/api/tests/test_api.py
  5. 16
      app/api/views.py
  6. 4
      app/server/static/components/annotationMixin.js
  7. 12
      app/server/static/components/document_classification.vue
  8. 13
      app/server/static/components/projects.vue

18
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),
),
]

1
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])

2
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')

38
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)

16
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'

4
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;

12
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,

13
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.',

Loading…
Cancel
Save