Repository: doccano/doccano

Issue:
Errors on import labels are not displayed in the UI
Description:
* Go to Labels section.
* Import Labels.
* Upload a file with some error.
* The modal disappears, no label is added but no error is displayed either. I can see server errors in the HTTP response with developer tools (in my case, `{"background_color":["Ensure this field has no more than 7 characters."]}`), but apparently there is no error displayed in the UI.

Environment:
*  Operating System: Ubuntu Linux
*  How did you install doccano: docker-compose up (from commit 29d187e22621ddcda21db50cb98598d0c44f3ae1)
* Browser: Firefox 70.0.1
Format for uploading labels
How to reproduce the behaviour
---------
What is the required format/structure for uploading the labels? When I click the import labels from the Actions menu, the file picker dialog suggests a json file. But the format/jso structure for the labels isn't documented. 

Your Environment
---------
<!-- Include details of your environment. -->

*   Operating System: Ubuntu (18.04)
*   Python Version Used:3.7.4
*   When you install doccano: 04/March/2020
*   How did you install doccano (Heroku button etc):  using the docker command



Discussion:

Resolving patch:
diff --git a/app/api/urls.py b/app/api/urls.py
--- a/app/api/urls.py
+++ b/app/api/urls.py
@@ -4,7 +4,7 @@
 
 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 @@
          StatisticsAPI.as_view(), name='statistics'),
     path('projects/<int:project_id>/labels',
          LabelList.as_view(), name='label_list'),
+    path('projects/<int:project_id>/label-upload',
+         LabelUploadAPI.as_view(), name='label_upload'),
     path('projects/<int:project_id>/labels/<int:label_id>',
          LabelDetail.as_view(), name='label_detail'),
     path('projects/<int:project_id>/docs',
diff --git a/app/api/views.py b/app/api/views.py
--- 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
@@ -366,3 +369,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)


Test patch:
diff --git a/app/api/tests/data/invalid_labels.json b/app/api/tests/data/invalid_labels.json
new file mode 100644
--- /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
--- /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
--- a/app/api/tests/test_api.py
+++ b/app/api/tests/test_api.py
@@ -389,6 +389,53 @@ def doCleanups(cls):
         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