Browse Source

Merge pull request #258 from CatalystCode/enhancement/approve-labels

Enhancement/Approve labels
pull/266/head
Hiroki Nakayama 5 years ago
committed by GitHub
parent
commit
9d7cbfe0c6
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 125 additions and 11 deletions
  1. 21
      app/api/migrations/0002_approve_document_labels.py
  2. 1
      app/api/models.py
  3. 8
      app/api/serializers.py
  4. 34
      app/api/tests/test_api.py
  5. 4
      app/api/urls.py
  6. 11
      app/api/views.py
  7. 11
      app/server/static/components/annotation.pug
  8. 32
      app/server/static/components/annotationMixin.js
  9. 1
      app/server/static/components/http.js
  10. 13
      app/server/static/components/projects.vue

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

1
app/api/models.py

@ -185,6 +185,7 @@ class Document(models.Model):
meta = models.TextField(default='{}') meta = models.TextField(default='{}')
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
annotations_approved_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
def __str__(self): def __str__(self):
return self.text[:50] return self.text[:50]

8
app/api/serializers.py

@ -54,6 +54,7 @@ class LabelSerializer(serializers.ModelSerializer):
class DocumentSerializer(serializers.ModelSerializer): class DocumentSerializer(serializers.ModelSerializer):
annotations = serializers.SerializerMethodField() annotations = serializers.SerializerMethodField()
annotation_approver = serializers.SerializerMethodField()
def get_annotations(self, instance): def get_annotations(self, instance):
request = self.context.get('request') request = self.context.get('request')
@ -66,9 +67,14 @@ class DocumentSerializer(serializers.ModelSerializer):
serializer = serializer(annotations, many=True) serializer = serializer(annotations, many=True)
return serializer.data return serializer.data
@classmethod
def get_annotation_approver(cls, instance):
approver = instance.annotations_approved_by
return approver.username if approver else None
class Meta: class Meta:
model = Document model = Document
fields = ('id', 'text', 'annotations', 'meta')
fields = ('id', 'text', 'annotations', 'meta', 'annotation_approver')
class ProjectSerializer(serializers.ModelSerializer): class ProjectSerializer(serializers.ModelSerializer):

34
app/api/tests/test_api.py

@ -426,6 +426,40 @@ class TestDocumentDetailAPI(APITestCase):
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) 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): class TestAnnotationListAPI(APITestCase):
@classmethod @classmethod

4
app/api/urls.py

@ -3,7 +3,7 @@ from rest_framework.urlpatterns import format_suffix_patterns
from .views import Me, Features from .views import Me, Features
from .views import ProjectList, ProjectDetail from .views import ProjectList, ProjectDetail
from .views import LabelList, LabelDetail
from .views import LabelList, LabelDetail, ApproveLabelsAPI
from .views import DocumentList, DocumentDetail from .views import DocumentList, DocumentDetail
from .views import AnnotationList, AnnotationDetail from .views import AnnotationList, AnnotationDetail
from .views import TextUploadAPI, TextDownloadAPI, CloudUploadAPI from .views import TextUploadAPI, TextDownloadAPI, CloudUploadAPI
@ -26,6 +26,8 @@ urlpatterns = [
DocumentList.as_view(), name='doc_list'), DocumentList.as_view(), name='doc_list'),
path('projects/<int:project_id>/docs/<int:doc_id>', path('projects/<int:project_id>/docs/<int:doc_id>',
DocumentDetail.as_view(), name='doc_detail'), DocumentDetail.as_view(), name='doc_detail'),
path('projects/<int:project_id>/docs/<int:doc_id>/approve-labels',
ApproveLabelsAPI.as_view(), name='approve_labels'),
path('projects/<int:project_id>/docs/<int:doc_id>/annotations', path('projects/<int:project_id>/docs/<int:doc_id>/annotations',
AnnotationList.as_view(), name='annotation_list'), AnnotationList.as_view(), name='annotation_list'),
path('projects/<int:project_id>/docs/<int:doc_id>/annotations/<int:annotation_id>', path('projects/<int:project_id>/docs/<int:doc_id>/annotations/<int:annotation_id>',

11
app/api/views.py

@ -97,6 +97,17 @@ class StatisticsAPI(APIView):
return label_count, user_count 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): class LabelList(generics.ListCreateAPIView):
queryset = Label.objects.all() queryset = Label.objects.all()
serializer_class = LabelSerializer serializer_class = LabelSerializer

11
app/server/static/components/annotation.pug

@ -106,11 +106,20 @@ div.columns(v-cloak="")
v-bind:value="achievement" v-bind:value="achievement"
max="100" max="100"
) 30% ) 30%
div.column.is-7
div.column.is-6
span.ml10 span.ml10
strong {{ total - remaining }} strong {{ total - remaining }}
| / | /
span {{ total }} 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 div.column.is-1.has-text-right
a.button(v-on:click="isAnnotationGuidelineActive = !isAnnotationGuidelineActive") a.button(v-on:click="isAnnotationGuidelineActive = !isAnnotationGuidelineActive")
span.icon span.icon

32
app/server/static/components/annotationMixin.js

@ -1,7 +1,9 @@
import * as marked from 'marked'; import * as marked from 'marked';
import VueJsonPretty from 'vue-json-pretty'; import VueJsonPretty from 'vue-json-pretty';
import isEmpty from 'lodash.isempty'; import isEmpty from 'lodash.isempty';
import HTTP from './http';
import HTTP, { rootUrl, newHttpClient } from './http';
const httpClient = newHttpClient();
const getOffsetFromUrl = (url) => { const getOffsetFromUrl = (url) => {
const offsetMatch = url.match(/[?#].*offset=(\d+)/); const offsetMatch = url.match(/[?#].*offset=(\d+)/);
@ -52,6 +54,7 @@ export default {
offset: getOffsetFromUrl(window.location.href), offset: getOffsetFromUrl(window.location.href),
picked: 'all', picked: 'all',
count: 0, count: 0,
isSuperuser: false,
isMetadataActive: false, isMetadataActive: false,
isAnnotationGuidelineActive: false, isAnnotationGuidelineActive: false,
}; };
@ -135,6 +138,17 @@ export default {
} }
return shortcut; 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: { watch: {
@ -162,6 +176,9 @@ export default {
HTTP.get().then((response) => { HTTP.get().then((response) => {
this.guideline = response.data.guideline; this.guideline = response.data.guideline;
}); });
httpClient.get(`${rootUrl}/v1/me`).then((response) => {
this.isSuperuser = response.data.is_superuser;
});
this.submit(); 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() { documentMetadata() {
const document = this.docs[this.pageNumber]; const document = this.docs[this.pageNumber];
if (document == null || document.meta == null) { if (document == null || document.meta == null) {

1
app/server/static/components/http.js

@ -7,5 +7,6 @@ const HTTP = axios.create({
baseURL: `/v1/${baseUrl}`, baseURL: `/v1/${baseUrl}`,
}); });
export const rootUrl = window.location.href.split('/').slice(0, 3).join('/');
export const newHttpClient = axios.create; export const newHttpClient = axios.create;
export default HTTP; export default HTTP;

13
app/server/static/components/projects.vue

@ -120,9 +120,8 @@
<script> <script>
import { title, daysAgo } from './filter'; import { title, daysAgo } from './filter';
import { newHttpClient } from './http';
import { rootUrl, newHttpClient } from './http';
const baseUrl = window.location.href.split('/').slice(0, 3).join('/');
const httpClient = newHttpClient(); const httpClient = newHttpClient();
export default { export default {
@ -153,8 +152,8 @@ export default {
created() { created() {
Promise.all([ Promise.all([
httpClient.get(`${baseUrl}/v1/projects`),
httpClient.get(`${baseUrl}/v1/me`),
httpClient.get(`${rootUrl}/v1/projects`),
httpClient.get(`${rootUrl}/v1/me`),
]).then(([projects, me]) => { ]).then(([projects, me]) => {
this.items = projects.data; this.items = projects.data;
this.username = me.data.username; this.username = me.data.username;
@ -164,7 +163,7 @@ export default {
methods: { methods: {
deleteProject() { deleteProject() {
httpClient.delete(`${baseUrl}/v1/projects/${this.project.id}`).then(() => {
httpClient.delete(`${rootUrl}/v1/projects/${this.project.id}`).then(() => {
this.isDelete = false; this.isDelete = false;
const index = this.items.indexOf(this.project); const index = this.items.indexOf(this.project);
this.items.splice(index, 1); this.items.splice(index, 1);
@ -198,9 +197,9 @@ export default {
guideline: 'Please write annotation guideline.', guideline: 'Please write annotation guideline.',
resourcetype: this.resourceType(), resourcetype: this.resourceType(),
}; };
httpClient.post(`${baseUrl}/v1/projects`, payload)
httpClient.post(`${rootUrl}/v1/projects`, payload)
.then((response) => { .then((response) => {
window.location = `${baseUrl}/projects/${response.data.id}/docs/create`;
window.location = `${rootUrl}/projects/${response.data.id}/docs/create`;
}) })
.catch((error) => { .catch((error) => {
this.projectTypeError = ''; this.projectTypeError = '';

Loading…
Cancel
Save