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='{}')
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]

8
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):

34
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

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 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/<int:project_id>/docs/<int:doc_id>',
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',
AnnotationList.as_view(), name='annotation_list'),
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
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

11
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

32
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) {

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

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

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

@ -120,9 +120,8 @@
<script>
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();
export default {
@ -153,8 +152,8 @@ export default {
created() {
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]) => {
this.items = projects.data;
this.username = me.data.username;
@ -164,7 +163,7 @@ export default {
methods: {
deleteProject() {
httpClient.delete(`${baseUrl}/v1/projects/${this.project.id}`).then(() => {
httpClient.delete(`${rootUrl}/v1/projects/${this.project.id}`).then(() => {
this.isDelete = false;
const index = this.items.indexOf(this.project);
this.items.splice(index, 1);
@ -198,9 +197,9 @@ export default {
guideline: 'Please write annotation guideline.',
resourcetype: this.resourceType(),
};
httpClient.post(`${baseUrl}/v1/projects`, payload)
httpClient.post(`${rootUrl}/v1/projects`, payload)
.then((response) => {
window.location = `${baseUrl}/projects/${response.data.id}/docs/create`;
window.location = `${rootUrl}/projects/${response.data.id}/docs/create`;
})
.catch((error) => {
this.projectTypeError = '';

Loading…
Cancel
Save