Browse Source

Add option to comment on documents

pull/496/head
Clemens Wolff 5 years ago
parent
commit
12b1dc7474
12 changed files with 253 additions and 5 deletions
  1. 27
      app/api/migrations/0002_comment.py
  2. 8
      app/api/models.py
  3. 9
      app/api/permissions.py
  4. 16
      app/api/serializers.py
  5. 89
      app/api/tests/test_api.py
  6. 5
      app/api/urls.py
  7. 26
      app/api/views.py
  8. 26
      app/server/static/components/annotation.pug
  9. 46
      app/server/static/components/annotationMixin.js
  10. 2
      app/server/static/pages/document_classification.js
  11. 2
      app/server/static/pages/seq2seq.js
  12. 2
      app/server/static/pages/sequence_labeling.js

27
app/api/migrations/0002_comment.py

@ -0,0 +1,27 @@
# Generated by Django 2.1.11 on 2019-12-12 19:11
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.CreateModel(
name='Comment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('text', models.TextField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='api.Document')),
('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

8
app/api/models.py

@ -196,6 +196,14 @@ class Document(models.Model):
return self.text[:50]
class Comment(models.Model):
text = models.TextField()
document = models.ForeignKey(Document, related_name='comments', on_delete=models.CASCADE)
user = models.ForeignKey(User, on_delete=models.CASCADE, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Annotation(models.Model):
objects = AnnotationManager()

9
app/api/permissions.py

@ -46,6 +46,15 @@ class IsOwnAnnotation(ProjectMixin, BasePermission):
return annotation.exists()
class IsOwnComment(ProjectMixin, BasePermission):
def has_object_permission(self, request, view, obj):
if request.user.is_superuser:
return True
return obj.user.id == request.user.id
class RolePermission(ProjectMixin, BasePermission):
UNSAFE_METHODS = ('POST', 'PATCH', 'DELETE')
unsafe_methods_check = True

16
app/api/serializers.py

@ -6,7 +6,7 @@ from rest_polymorphic.serializers import PolymorphicSerializer
from rest_framework.exceptions import ValidationError
from .models import Label, Project, Document, RoleMapping, Role
from .models import Label, Project, Document, RoleMapping, Role, Comment
from .models import TextClassificationProject, SequenceLabelingProject, Seq2seqProject
from .models import DocumentAnnotation, SequenceAnnotation, Seq2seqAnnotation
@ -62,6 +62,7 @@ class LabelSerializer(serializers.ModelSerializer):
class DocumentSerializer(serializers.ModelSerializer):
annotations = serializers.SerializerMethodField()
annotation_approver = serializers.SerializerMethodField()
comments = serializers.SerializerMethodField()
def get_annotations(self, instance):
request = self.context.get('request')
@ -79,9 +80,13 @@ class DocumentSerializer(serializers.ModelSerializer):
approver = instance.annotations_approved_by
return approver.username if approver else None
@classmethod
def get_comments(cls, instance):
return CommentSerializer(instance.comments, many=True).data
class Meta:
model = Document
fields = ('id', 'text', 'annotations', 'meta', 'annotation_approver')
fields = ('id', 'text', 'annotations', 'meta', 'annotation_approver', 'comments')
class ProjectSerializer(serializers.ModelSerializer):
@ -211,3 +216,10 @@ class RoleMappingSerializer(serializers.ModelSerializer):
class Meta:
model = RoleMapping
fields = ('id', 'user', 'role', 'username', 'rolename')
class CommentSerializer(serializers.ModelSerializer):
class Meta:
model = Comment
fields = ('id', 'text', 'user')
read_only_fields = ('user', 'document')

89
app/api/tests/test_api.py

@ -789,6 +789,95 @@ class TestAnnotationDetailAPI(APITestCase):
remove_all_role_mappings()
class TestCommentListAPI(APITestCase):
@classmethod
def setUpTestData(cls):
cls.project_member_name = 'project_member_name'
cls.project_member_pass = 'project_member_pass'
cls.another_project_member_name = 'another_project_member_name'
cls.another_project_member_pass = 'another_project_member_pass'
cls.non_project_member_name = 'non_project_member_name'
cls.non_project_member_pass = 'non_project_member_pass'
create_default_roles()
cls.project_member = User.objects.create_user(username=cls.project_member_name,
password=cls.project_member_pass)
another_project_member = User.objects.create_user(username=cls.another_project_member_name,
password=cls.another_project_member_pass)
User.objects.create_user(username=cls.non_project_member_name, password=cls.non_project_member_pass)
main_project = mommy.make('SequenceLabelingProject', users=[cls.project_member, another_project_member])
main_project_doc = mommy.make('Document', project=main_project)
cls.comment = mommy.make('Comment', document=main_project_doc, text='comment 1', user=cls.project_member)
mommy.make('Comment', document=main_project_doc, text='comment 2', user=cls.project_member)
mommy.make('Comment', document=main_project_doc, text='comment 3', user=another_project_member)
cls.url = reverse(viewname='comment_list', args=[main_project.id, main_project_doc.id])
assign_user_to_role(project_member=cls.project_member, project=main_project,
role_name=settings.ROLE_ANNOTATOR)
assign_user_to_role(project_member=another_project_member, project=main_project,
role_name=settings.ROLE_ANNOTATOR)
def test_returns_own_comments_to_project_member(self):
self.client.login(username=self.project_member_name,
password=self.project_member_pass)
response = self.client.get(self.url, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data['results']), 2)
self.client.login(username=self.another_project_member_name,
password=self.another_project_member_pass)
response = self.client.get(self.url, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data['results']), 1)
def test_does_not_return_comments_to_non_project_member(self):
self.client.login(username=self.non_project_member_name,
password=self.non_project_member_pass)
response = self.client.get(self.url, format='json')
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_does_not_allow_deletion_by_non_project_member(self):
self.client.login(username=self.non_project_member_name,
password=self.non_project_member_pass)
response = self.client.delete(f"{self.url}/{self.comment.id}", format='json')
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_does_not_allow_deletion_of_non_owned_comment(self):
self.client.login(username=self.another_project_member_name,
password=self.another_project_member_pass)
response = self.client.delete(f"{self.url}/{self.comment.id}", format='json')
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_create_update_delete_comment(self):
self.client.login(username=self.project_member_name,
password=self.project_member_pass)
response = self.client.post(self.url, format='json', data={'text': 'comment'})
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data['user'], self.project_member.id)
self.assertEqual(response.data['text'], 'comment')
url = f"{self.url}/{response.data['id']}"
response = self.client.get(self.url, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data['results']), 3)
response = self.client.patch(url, format='json', data={'text': 'new comment'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['text'], 'new comment')
response = self.client.delete(url)
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
response = self.client.get(self.url, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data['results']), 2)
@classmethod
def doCleanups(cls):
remove_all_role_mappings()
class TestSearch(APITestCase):
@classmethod

5
app/api/urls.py

@ -7,6 +7,7 @@ from .views import ProjectList, ProjectDetail
from .views import LabelList, LabelDetail, ApproveLabelsAPI
from .views import DocumentList, DocumentDetail
from .views import AnnotationList, AnnotationDetail
from .views import CommentList, CommentDetail
from .views import TextUploadAPI, TextDownloadAPI, CloudUploadAPI
from .views import StatisticsAPI
from .views import RoleMappingList, RoleMappingDetail, Roles
@ -36,6 +37,10 @@ urlpatterns = [
AnnotationList.as_view(), name='annotation_list'),
path('projects/<int:project_id>/docs/<int:doc_id>/annotations/<int:annotation_id>',
AnnotationDetail.as_view(), name='annotation_detail'),
path('projects/<int:project_id>/docs/<int:doc_id>/comments',
CommentList.as_view(), name='comment_list'),
path('projects/<int:project_id>/docs/<int:doc_id>/comments/<int:comment_id>',
CommentDetail.as_view(), name='comment_detail'),
path('projects/<int:project_id>/docs/upload',
TextUploadAPI.as_view(), name='doc_uploader'),
path('projects/<int:project_id>/docs/download',

26
app/api/views.py

@ -14,10 +14,11 @@ from rest_framework.parsers import MultiPartParser
from rest_framework_csv.renderers import CSVRenderer
from .filters import DocumentFilter
from .models import Project, Label, Document, RoleMapping, Role
from .models import Project, Label, Document, RoleMapping, Role, Comment
from .permissions import IsProjectAdmin, IsAnnotatorAndReadOnly, IsAnnotator, IsAnnotationApproverAndReadOnly, IsOwnAnnotation, IsAnnotationApprover
from .permissions import IsOwnComment
from .serializers import ProjectSerializer, LabelSerializer, DocumentSerializer, UserSerializer
from .serializers import ProjectPolymorphicSerializer, RoleMappingSerializer, RoleSerializer
from .serializers import ProjectPolymorphicSerializer, RoleMappingSerializer, RoleSerializer, CommentSerializer
from .utils import CSVParser, ExcelParser, JSONParser, PlainTextParser, CoNLLParser, iterable_to_io
from .utils import JSONLRenderer
from .utils import JSONPainter, CSVPainter
@ -206,6 +207,27 @@ class AnnotationDetail(generics.RetrieveUpdateDestroyAPIView):
return self.queryset
class CommentList(generics.ListCreateAPIView):
serializer_class = CommentSerializer
permission_classes = [IsAuthenticated & IsInProjectOrAdmin]
def get_queryset(self):
return Comment.objects.filter(
document_id=self.kwargs['doc_id'],
user_id=self.request.user.id,
).all()
def perform_create(self, serializer):
serializer.save(user=self.request.user, document_id=self.kwargs['doc_id'])
class CommentDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = Comment.objects.all()
serializer_class = CommentSerializer
lookup_url_kwarg = 'comment_id'
permission_classes = [IsAuthenticated & IsInProjectOrAdmin & IsOwnComment]
class TextUploadAPI(APIView):
parser_classes = (MultiPartParser,)
permission_classes = [IsAuthenticated & IsProjectAdmin]

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

@ -108,6 +108,23 @@ div.columns(v-cloak="")
v-bind:show-line="false"
)
div.modal(v-bind:class="{ 'is-active': isCommentActive }")
div.modal-background
div.modal-card
header.modal-card-head
p.modal-card-title Document Comment
button.delete(
v-on:click="toggleCommentModal()"
aria-label="close"
)
section.modal-card-body.modal-card-body-footer
textarea.textarea(
v-model="comment"
v-debounce="syncComment"
type="text"
placeholder="Add document comment here..."
)
div.columns.is-multiline.is-gapless.is-mobile.is-vertical-center
div.column.is-3
progress.progress.is-inline-block(
@ -115,7 +132,7 @@ div.columns(v-cloak="")
v-bind:value="achievement"
max="100"
) 30%
div.column.is-6
div.column.is-5
span.ml10
strong {{ total - remaining }}
| /
@ -141,6 +158,13 @@ div.columns(v-cloak="")
)
span.icon
i.fas.fa-box
div.column.is-1.has-text-right
a.button.tooltip.is-tooltip-bottom(
v-on:click="toggleCommentModal()"
v-bind:data-tooltip="'Click to comment on document.'"
)
span.icon
i.fas.fa-comment
div.columns
div.column

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

@ -88,17 +88,50 @@ export default {
offset: getOffsetFromUrl(window.location.href),
picked: 'all',
ordering: '',
comments: [],
comment: '',
count: 0,
prevLimit: 0,
paginationPages: 0,
paginationPage: 0,
isAnnotationApprover: false,
isCommentActive: false,
isMetadataActive: false,
isAnnotationGuidelineActive: false,
};
},
methods: {
async syncComment(text) {
const docId = this.docs[this.pageNumber].id;
const commentId = this.comments[this.pageNumber].id;
const hasText = text.trim().length > 0;
if (commentId && !hasText) {
await HTTP.delete(`docs/${docId}/comments/${commentId}`);
const comments = this.comments.slice();
comments[this.pageNumber] = { text: '', id: null };
this.comments = comments;
} else if (commentId && hasText) {
await HTTP.patch(`docs/${docId}/comments/${commentId}`, { text });
} else {
const response = await HTTP.post(`docs/${docId}/comments`, { text });
const comments = this.comments.slice();
comments[this.pageNumber] = response.data;
this.comments = comments;
}
},
async toggleCommentModal() {
if (this.isCommentActive) {
this.isCommentActive = false;
return;
}
this.comment = this.comments[this.pageNumber].text;
this.isCommentActive = true;
},
resetScrollbar() {
const textbox = this.$refs.textbox;
if (textbox) {
@ -228,9 +261,22 @@ export default {
this.docs = documents;
});
},
async fetchComments() {
const responses = await Promise.all(this.docs.map(doc => HTTP.get(`docs/${doc.id}/comments`)));
this.comments = responses.map(response => response.data.results[0] || { text: '', id: null });
},
},
watch: {
pageNumber() {
this.comment = this.comments[this.pageNumber].text;
},
docs() {
this.fetchComments();
},
picked() {
this.submit();
},

2
app/server/static/pages/document_classification.js

@ -1,6 +1,8 @@
import Vue from 'vue';
import vueDebounce from 'vue-debounce';
import DocumentClassification from '../components/document_classification.vue';
Vue.use(vueDebounce);
Vue.use(require('vue-shortkey'), {
prevent: ['input', 'textarea'],
});

2
app/server/static/pages/seq2seq.js

@ -1,6 +1,8 @@
import Vue from 'vue';
import vueDebounce from 'vue-debounce';
import Seq2Seq from '../components/seq2seq.vue';
Vue.use(vueDebounce);
Vue.use(require('vue-shortkey'));
new Vue({

2
app/server/static/pages/sequence_labeling.js

@ -1,6 +1,8 @@
import Vue from 'vue';
import vueDebounce from 'vue-debounce';
import SequenceLabeling from '../components/sequence_labeling.vue';
Vue.use(vueDebounce);
Vue.use(require('vue-shortkey'), {
prevent: ['input', 'textarea'],
});

Loading…
Cancel
Save