diff --git a/app/api/tests/data/invalid_labels.json b/app/api/tests/data/invalid_labels.json new file mode 100644 index 00000000..bfdc9417 --- /dev/null +++ b/app/api/tests/data/invalid_labels.json @@ -0,0 +1,18 @@ +[ + { + "id": 44, + "text": "Dog", + "prefix_key": null, + "suffix_key": "a", + "background_color": "#FF0000", + "text_color": "#ffffff" + }, + { + "id": 45, + "text": "Dog", + "prefix_key": null, + "suffix_key": "c", + "background_color": "#FF0000", + "text_color": "#ffffff" + } +] \ No newline at end of file diff --git a/app/api/tests/data/valid_labels.json b/app/api/tests/data/valid_labels.json new file mode 100644 index 00000000..6e055678 --- /dev/null +++ b/app/api/tests/data/valid_labels.json @@ -0,0 +1,18 @@ +[ + { + "id": 44, + "text": "Dog", + "prefix_key": null, + "suffix_key": "a", + "background_color": "#FF0000", + "text_color": "#ffffff" + }, + { + "id": 45, + "text": "Cat", + "prefix_key": null, + "suffix_key": "c", + "background_color": "#FF0000", + "text_color": "#ffffff" + } +] \ No newline at end of file diff --git a/app/api/tests/test_api.py b/app/api/tests/test_api.py index 92f699b4..55303f74 100644 --- a/app/api/tests/test_api.py +++ b/app/api/tests/test_api.py @@ -389,6 +389,53 @@ class TestLabelDetailAPI(APITestCase): remove_all_role_mappings() +class TestLabelUploadAPI(APITestCase): + + @classmethod + def setUpTestData(cls): + cls.project_member_name = 'project_member_name' + cls.project_member_pass = 'project_member_pass' + cls.non_project_member_name = 'non_project_member_name' + cls.non_project_member_pass = 'non_project_member_pass' + cls.super_user_name = 'super_user_name' + cls.super_user_pass = 'super_user_pass' + create_default_roles() + project_member = User.objects.create_user(username=cls.project_member_name, + password=cls.project_member_pass) + User.objects.create_user(username=cls.non_project_member_name, password=cls.non_project_member_pass) + project_admin = User.objects.create_user(username=cls.super_user_name, + password=cls.super_user_pass) + project = mommy.make('Project', users=[project_member, project_admin]) + cls.url = reverse(viewname='label_upload', args=[project.id]) + create_default_roles() + assign_user_to_role(project_member=project_admin, project=project, role_name=settings.ROLE_PROJECT_ADMIN) + assign_user_to_role(project_member=project_member, project=project, role_name=settings.ROLE_ANNOTATOR) + + def help_to_upload_file(self, filename, expected_status): + with open(os.path.join(DATA_DIR, filename), 'rb') as f: + response = self.client.post(self.url, data={'file': f}) + self.assertEqual(response.status_code, expected_status) + + def test_allows_project_admin_to_upload_label(self): + self.client.login(username=self.super_user_name, + password=self.super_user_pass) + self.help_to_upload_file('valid_labels.json', status.HTTP_201_CREATED) + + def test_disallows_project_member_to_upload_label(self): + self.client.login(username=self.project_member_name, + password=self.project_member_pass) + self.help_to_upload_file('valid_labels.json', status.HTTP_403_FORBIDDEN) + + def test_try_to_upload_invalid_file(self): + self.client.login(username=self.super_user_name, + password=self.super_user_pass) + self.help_to_upload_file('invalid_labels.json', status.HTTP_400_BAD_REQUEST) + + @classmethod + def doCleanups(cls): + remove_all_role_mappings() + + class TestDocumentListAPI(APITestCase, TestUtilsMixin): @classmethod diff --git a/app/api/urls.py b/app/api/urls.py index dc1baee6..b6154c57 100644 --- a/app/api/urls.py +++ b/app/api/urls.py @@ -4,7 +4,7 @@ from rest_framework.urlpatterns import format_suffix_patterns from .views import Me, Features, Users from .views import ProjectList, ProjectDetail -from .views import LabelList, LabelDetail, ApproveLabelsAPI +from .views import LabelList, LabelDetail, ApproveLabelsAPI, LabelUploadAPI from .views import DocumentList, DocumentDetail from .views import AnnotationList, AnnotationDetail from .views import TextUploadAPI, TextDownloadAPI, CloudUploadAPI @@ -24,6 +24,8 @@ urlpatterns = [ StatisticsAPI.as_view(), name='statistics'), path('projects//labels', LabelList.as_view(), name='label_list'), + path('projects//label-upload', + LabelUploadAPI.as_view(), name='label_upload'), path('projects//labels/', LabelDetail.as_view(), name='label_detail'), path('projects//docs', diff --git a/app/api/views.py b/app/api/views.py index 02c13521..a781577a 100644 --- a/app/api/views.py +++ b/app/api/views.py @@ -1,5 +1,8 @@ +import json from django.conf import settings from django.contrib.auth.models import User +from django.db import transaction +from django.db.utils import IntegrityError from django.shortcuts import get_object_or_404, redirect from django_filters.rest_framework import DjangoFilterBackend from django.db.models import Count, F, Q @@ -368,3 +371,24 @@ class RoleMappingDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = RoleMappingSerializer lookup_url_kwarg = 'rolemapping_id' permission_classes = [IsAuthenticated & IsProjectAdmin] + + +class LabelUploadAPI(APIView): + parser_classes = (MultiPartParser,) + permission_classes = [IsAuthenticated & IsProjectAdmin] + + @transaction.atomic + def post(self, request, *args, **kwargs): + if 'file' not in request.data: + raise ParseError('Empty content') + labels = json.load(request.data['file']) + project = get_object_or_404(Project, pk=kwargs['project_id']) + try: + for label in labels: + serializer = LabelSerializer(data=label) + serializer.is_valid(raise_exception=True) + serializer.save(project=project) + return Response(status=status.HTTP_201_CREATED) + except IntegrityError: + content = {'error': 'IntegrityError: you cannot create a label with same name or shortkey.'} + return Response(content, status=status.HTTP_400_BAD_REQUEST) diff --git a/frontend/components/containers/labels/LabelActionMenu.vue b/frontend/components/containers/labels/LabelActionMenu.vue index aaa4c8b2..2f337908 100644 --- a/frontend/components/containers/labels/LabelActionMenu.vue +++ b/frontend/components/containers/labels/LabelActionMenu.vue @@ -15,7 +15,7 @@ @@ -58,7 +58,7 @@ export default { }, methods: { - ...mapActions('labels', ['createLabel', 'importLabels', 'exportLabels']), + ...mapActions('labels', ['createLabel', 'uploadLabel', 'exportLabels']), ...mapActions('projects', ['setCurrentProject']), handleDownload() { diff --git a/frontend/components/organisms/labels/LabelImportForm.vue b/frontend/components/organisms/labels/LabelImportForm.vue index bc6a6b9c..90ffa53a 100644 --- a/frontend/components/organisms/labels/LabelImportForm.vue +++ b/frontend/components/organisms/labels/LabelImportForm.vue @@ -21,6 +21,10 @@ The file could not be uploaded. Maybe invalid format. Please check available formats carefully. +

Example format

+ + {{ exampleFormat }} +

Select a file

{}, required: true @@ -57,6 +61,27 @@ export default { } }, + computed: { + exampleFormat() { + const data = [ + { + text: 'Dog', + suffix_key: 'a', + background_color: '#FF0000', + text_color: '#ffffff' + }, + { + text: 'Cat', + suffix_key: 'c', + background_color: '#FF0000', + text_color: '#ffffff' + } + ] + console.log(JSON.stringify(data, null, 4)) + return JSON.stringify(data, null, 4) + } + }, + methods: { cancel() { this.$emit('close') @@ -69,7 +94,7 @@ export default { }, create() { if (this.validate()) { - this.importLabel({ + this.uploadLabel({ projectId: this.$route.params.id, file: this.file }) @@ -85,3 +110,13 @@ export default { } } + + diff --git a/frontend/services/label.service.js b/frontend/services/label.service.js index d99affaa..053c8a14 100644 --- a/frontend/services/label.service.js +++ b/frontend/services/label.service.js @@ -20,6 +20,10 @@ class LabelService { updateLabel(projectId, labelId, payload) { return this.request.patch(`/projects/${projectId}/labels/${labelId}`, payload) } + + uploadFile(projectId, payload, config = {}) { + return this.request.post(`/projects/${projectId}/label-upload`, payload, config) + } } export default new LabelService() diff --git a/frontend/store/labels.js b/frontend/store/labels.js index 4ebf6883..e2ed7359 100644 --- a/frontend/store/labels.js +++ b/frontend/store/labels.js @@ -115,5 +115,22 @@ export const actions = { .finally(() => { commit('setLoading', false) }) + }, + uploadLabel({ commit, dispatch }, data) { + commit('setLoading', true) + const formData = new FormData() + formData.append('file', data.file) + const config = { + headers: { + 'Content-Type': 'multipart/form-data' + } + } + return LabelService.uploadFile(data.projectId, formData, config) + .then((response) => { + dispatch('getLabelList', data) + }) + .finally(() => { + commit('setLoading', false) + }) } }