diff --git a/app/api/migrations/0002_approve_document_labels.py b/app/api/migrations/0002_approve_document_labels.py new file mode 100644 index 00000000..9d284a2e --- /dev/null +++ b/app/api/migrations/0002_approve_document_labels.py @@ -0,0 +1,21 @@ +# Generated by Django 2.1.7 on 2019-06-26 13:24 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('api', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='document', + name='annotations_approved_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/app/api/models.py b/app/api/models.py index 2b661189..573d7661 100644 --- a/app/api/models.py +++ b/app/api/models.py @@ -185,6 +185,7 @@ class Document(models.Model): meta = models.TextField(default='{}') created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + annotations_approved_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True) def __str__(self): return self.text[:50] diff --git a/app/api/serializers.py b/app/api/serializers.py index 783da955..7eab26f1 100644 --- a/app/api/serializers.py +++ b/app/api/serializers.py @@ -54,6 +54,7 @@ class LabelSerializer(serializers.ModelSerializer): class DocumentSerializer(serializers.ModelSerializer): annotations = serializers.SerializerMethodField() + annotation_approver = serializers.SerializerMethodField() def get_annotations(self, instance): request = self.context.get('request') @@ -66,9 +67,14 @@ class DocumentSerializer(serializers.ModelSerializer): serializer = serializer(annotations, many=True) return serializer.data + @classmethod + def get_annotation_approver(cls, instance): + approver = instance.annotations_approved_by + return approver.username if approver else None + class Meta: model = Document - fields = ('id', 'text', 'annotations', 'meta') + fields = ('id', 'text', 'annotations', 'meta', 'annotation_approver') class ProjectSerializer(serializers.ModelSerializer): diff --git a/app/api/tests/test_api.py b/app/api/tests/test_api.py index 3a97fc02..25721aa1 100644 --- a/app/api/tests/test_api.py +++ b/app/api/tests/test_api.py @@ -426,6 +426,40 @@ class TestDocumentDetailAPI(APITestCase): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) +class TestApproveLabelsAPI(APITestCase): + @classmethod + def setUpTestData(cls): + cls.project_member_name = 'project_member_name' + cls.project_member_pass = 'project_member_pass' + cls.super_user_name = 'super_user_name' + cls.super_user_pass = 'super_user_pass' + project_member = User.objects.create_user(username=cls.project_member_name, + password=cls.project_member_pass) + # Todo: change super_user to project_admin. + super_user = User.objects.create_superuser(username=cls.super_user_name, + password=cls.super_user_pass, + email='fizz@buzz.com') + project = mommy.make('TextClassificationProject', users=[project_member, super_user]) + cls.doc = mommy.make('Document', project=project) + cls.url = reverse(viewname='approve_labels', args=[project.id, cls.doc.id]) + + def test_allows_superuser_to_approve_and_disapprove_labels(self): + self.client.login(username=self.super_user_name, password=self.super_user_pass) + + response = self.client.post(self.url, format='json', data={'approved': True}) + self.assertEqual(response.data['annotation_approver'], self.super_user_name) + + response = self.client.post(self.url, format='json', data={'approved': False}) + self.assertIsNone(response.data['annotation_approver']) + + def test_disallows_project_member_to_approve_and_disapprove_labels(self): + self.client.login(username=self.project_member_name, password=self.project_member_pass) + + response = self.client.post(self.url, format='json', data={'approved': True}) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + class TestAnnotationListAPI(APITestCase): @classmethod diff --git a/app/api/urls.py b/app/api/urls.py index f33dc595..b06b1458 100644 --- a/app/api/urls.py +++ b/app/api/urls.py @@ -3,7 +3,7 @@ from rest_framework.urlpatterns import format_suffix_patterns from .views import Me, Features from .views import ProjectList, ProjectDetail -from .views import LabelList, LabelDetail +from .views import LabelList, LabelDetail, ApproveLabelsAPI from .views import DocumentList, DocumentDetail from .views import AnnotationList, AnnotationDetail from .views import TextUploadAPI, TextDownloadAPI, CloudUploadAPI @@ -26,6 +26,8 @@ urlpatterns = [ DocumentList.as_view(), name='doc_list'), path('projects//docs/', DocumentDetail.as_view(), name='doc_detail'), + path('projects//docs//approve-labels', + ApproveLabelsAPI.as_view(), name='approve_labels'), path('projects//docs//annotations', AnnotationList.as_view(), name='annotation_list'), path('projects//docs//annotations/', diff --git a/app/api/views.py b/app/api/views.py index 915eb350..7388a919 100644 --- a/app/api/views.py +++ b/app/api/views.py @@ -97,6 +97,17 @@ class StatisticsAPI(APIView): return label_count, user_count +class ApproveLabelsAPI(APIView): + permission_classes = (IsAuthenticated, IsProjectUser, IsAdminUser) + + def post(self, request, *args, **kwargs): + approved = self.request.data.get('approved', True) + document = get_object_or_404(Document, pk=self.kwargs['doc_id']) + document.annotations_approved_by = self.request.user if approved else None + document.save() + return Response(DocumentSerializer(document).data) + + class LabelList(generics.ListCreateAPIView): queryset = Label.objects.all() serializer_class = LabelSerializer diff --git a/app/server/static/components/annotation.pug b/app/server/static/components/annotation.pug index 6f445c0a..eea324da 100644 --- a/app/server/static/components/annotation.pug +++ b/app/server/static/components/annotation.pug @@ -106,11 +106,20 @@ div.columns(v-cloak="") v-bind:value="achievement" max="100" ) 30% - div.column.is-7 + div.column.is-6 span.ml10 strong {{ total - remaining }} | / span {{ total }} + + div.column.is-1.has-text-right + a.button.tooltip.is-tooltip-bottom( + v-if="isSuperuser" + v-on:click="approveDocumentAnnotations" + v-bind:data-tooltip="documentAnnotationsApprovalTooltip" + ) + span.icon + i.far(v-bind:class="[documentAnnotationsAreApproved ? 'fa-check-circle' : 'fa-circle']") div.column.is-1.has-text-right a.button(v-on:click="isAnnotationGuidelineActive = !isAnnotationGuidelineActive") span.icon diff --git a/app/server/static/components/annotationMixin.js b/app/server/static/components/annotationMixin.js index d0eb175b..5a58de87 100644 --- a/app/server/static/components/annotationMixin.js +++ b/app/server/static/components/annotationMixin.js @@ -1,7 +1,9 @@ import * as marked from 'marked'; import VueJsonPretty from 'vue-json-pretty'; import isEmpty from 'lodash.isempty'; -import HTTP from './http'; +import HTTP, { rootUrl, newHttpClient } from './http'; + +const httpClient = newHttpClient(); const getOffsetFromUrl = (url) => { const offsetMatch = url.match(/[?#].*offset=(\d+)/); @@ -52,6 +54,7 @@ export default { offset: getOffsetFromUrl(window.location.href), picked: 'all', count: 0, + isSuperuser: false, isMetadataActive: false, isAnnotationGuidelineActive: false, }; @@ -135,6 +138,17 @@ export default { } return shortcut; }, + + approveDocumentAnnotations() { + const document = this.docs[this.pageNumber]; + const approved = !this.documentAnnotationsAreApproved; + + HTTP.post(`docs/${document.id}/approve-labels`, { approved }).then((response) => { + const documents = this.docs.slice(); + documents[this.pageNumber] = response.data; + this.docs = documents; + }); + }, }, watch: { @@ -162,6 +176,9 @@ export default { HTTP.get().then((response) => { this.guideline = response.data.guideline; }); + httpClient.get(`${rootUrl}/v1/me`).then((response) => { + this.isSuperuser = response.data.is_superuser; + }); this.submit(); }, @@ -178,6 +195,19 @@ export default { }); }, + documentAnnotationsAreApproved() { + const document = this.docs[this.pageNumber]; + return document != null && document.annotation_approver != null; + }, + + documentAnnotationsApprovalTooltip() { + const document = this.docs[this.pageNumber]; + + return this.documentAnnotationsAreApproved + ? `Annotations approved by ${document.annotation_approver}, click to reject annotations` + : 'Click to approve annotations'; + }, + documentMetadata() { const document = this.docs[this.pageNumber]; if (document == null || document.meta == null) {