diff --git a/app/api/migrations/0002_comment.py b/app/api/migrations/0002_comment.py new file mode 100644 index 00000000..a8d4e164 --- /dev/null +++ b/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)), + ], + ), + ] diff --git a/app/api/models.py b/app/api/models.py index 697ceb0f..c8d9bd4b 100644 --- a/app/api/models.py +++ b/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() diff --git a/app/api/permissions.py b/app/api/permissions.py index 3900e5b0..4720c2bb 100644 --- a/app/api/permissions.py +++ b/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 diff --git a/app/api/serializers.py b/app/api/serializers.py index eaa2af2c..67d526bc 100644 --- a/app/api/serializers.py +++ b/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') diff --git a/app/api/tests/test_api.py b/app/api/tests/test_api.py index 2c8b66bf..e0d286d1 100644 --- a/app/api/tests/test_api.py +++ b/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 diff --git a/app/api/urls.py b/app/api/urls.py index b65d87a0..0e2a7df1 100644 --- a/app/api/urls.py +++ b/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//docs//annotations/', AnnotationDetail.as_view(), name='annotation_detail'), + path('projects//docs//comments', + CommentList.as_view(), name='comment_list'), + path('projects//docs//comments/', + CommentDetail.as_view(), name='comment_detail'), path('projects//docs/upload', TextUploadAPI.as_view(), name='doc_uploader'), path('projects//docs/download', diff --git a/app/api/views.py b/app/api/views.py index 4b31bcd8..e09f48f8 100644 --- a/app/api/views.py +++ b/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] diff --git a/app/server/static/components/annotation.pug b/app/server/static/components/annotation.pug index a3aaacf8..b56b4e34 100644 --- a/app/server/static/components/annotation.pug +++ b/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 diff --git a/app/server/static/components/annotationMixin.js b/app/server/static/components/annotationMixin.js index b1c1d2dd..39eb9d3e 100644 --- a/app/server/static/components/annotationMixin.js +++ b/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(); }, diff --git a/app/server/static/pages/document_classification.js b/app/server/static/pages/document_classification.js index 08552601..501bc69a 100644 --- a/app/server/static/pages/document_classification.js +++ b/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'], }); diff --git a/app/server/static/pages/seq2seq.js b/app/server/static/pages/seq2seq.js index f1289a67..d2520e8a 100644 --- a/app/server/static/pages/seq2seq.js +++ b/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({ diff --git a/app/server/static/pages/sequence_labeling.js b/app/server/static/pages/sequence_labeling.js index f72b6130..84f275f6 100644 --- a/app/server/static/pages/sequence_labeling.js +++ b/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'], });