Repository: doccano/doccano

Issue:
Unable to tag selected text in collab mode 
### Environment 
*   Operating System: Linux - Alpine 3.11
*   Python Version Used: 3.8.6
*   When you install doccano: July 14, 2021
*   How did you install doccano (Heroku button etc): one-click setup script provided on GitHub readme 


### The problem
Bug: when attempting to tag text, words/characters about 10-13 characters over end up being tagged, as opposed to the selected text. This issue persists across browsers (Chrome and Safari) and across all of our uploaded documents. When `collaboration` mode is turned on, all team members experience the issue. However, when we turn it off, it begins working perfectly again. Please see video: https://user-images.githubusercontent.com/21316094/136839200-8384dbcb-8b8f-49a5-9ae9-3235be6e6807.mov 

### To reproduce
Reproducible test case: multiple users should tag a particular document, turn collaborative mode on then try tagging that document and see if the text selected is the text that gets tagged.


Discussion:
Would you write your environment? Thank you!

Resolving patch:
diff --git a/backend/api/models.py b/backend/api/models.py
--- a/backend/api/models.py
+++ b/backend/api/models.py
@@ -284,14 +284,21 @@ class Span(Annotation):
 
     def validate_unique(self, exclude=None):
         allow_overlapping = getattr(self.example.project, 'allow_overlapping', False)
+        is_collaborative = self.example.project.collaborative_annotation
         if allow_overlapping:
             super().validate_unique(exclude=exclude)
+            return
+
+        overlapping_span = Span.objects.exclude(id=self.id).filter(example=self.example).filter(
+            models.Q(start_offset__gte=self.start_offset, start_offset__lt=self.end_offset) |
+            models.Q(end_offset__gt=self.start_offset, end_offset__lte=self.end_offset) |
+            models.Q(start_offset__lte=self.start_offset, end_offset__gte=self.end_offset)
+        )
+        if is_collaborative:
+            if overlapping_span.exists():
+                raise ValidationError('This overlapping is not allowed in this project.')
         else:
-            if Span.objects.exclude(id=self.id).filter(example=self.example).filter(
-                models.Q(start_offset__gte=self.start_offset, start_offset__lt=self.end_offset) |
-                models.Q(end_offset__gt=self.start_offset, end_offset__lte=self.end_offset) |
-                models.Q(start_offset__lte=self.start_offset, end_offset__gte=self.end_offset)
-            ).exists():
+            if overlapping_span.filter(user=self.user).exists():
                 raise ValidationError('This overlapping is not allowed in this project.')
 
     def save(self, force_insert=False, force_update=False, using=None,
diff --git a/backend/api/views/annotation.py b/backend/api/views/annotation.py
--- a/backend/api/views/annotation.py
+++ b/backend/api/views/annotation.py
@@ -1,3 +1,4 @@
+from django.core.exceptions import ValidationError
 from django.shortcuts import get_object_or_404
 from rest_framework import generics, status
 from rest_framework.permissions import IsAuthenticated
@@ -34,7 +35,11 @@ def create(self, request, *args, **kwargs):
         if self.project.single_class_classification:
             self.get_queryset().delete()
         request.data['example'] = self.kwargs['doc_id']
-        return super().create(request, args, kwargs)
+        try:
+            response = super().create(request, args, kwargs)
+        except ValidationError as err:
+            response = Response({'detail': err.messages}, status=status.HTTP_400_BAD_REQUEST)
+        return response
 
     def perform_create(self, serializer):
         serializer.save(example_id=self.kwargs['doc_id'], user=self.request.user)


Test patch:
diff --git a/backend/api/tests/api/utils.py b/backend/api/tests/api/utils.py
--- a/backend/api/tests/api/utils.py
+++ b/backend/api/tests/api/utils.py
@@ -48,7 +48,8 @@ def make_project(
         task: str,
         users: List[str],
         roles: List[str] = None,
-        collaborative_annotation=False):
+        collaborative_annotation=False,
+        **kwargs):
     create_default_roles()
 
     # create users.
@@ -68,7 +69,8 @@ def make_project(
         _model=project_model,
         project_type=task,
         users=users,
-        collaborative_annotation=collaborative_annotation
+        collaborative_annotation=collaborative_annotation,
+        **kwargs
     )
 
     # assign roles to the users.
@@ -119,7 +121,7 @@ def make_annotation(task, doc, user, **kwargs):
     return mommy.make(annotation_model, example=doc, user=user, **kwargs)
 
 
-def prepare_project(task: str = 'Any', collaborative_annotation=False):
+def prepare_project(task: str = 'Any', collaborative_annotation=False, **kwargs):
     return make_project(
         task=task,
         users=['admin', 'approver', 'annotator'],
@@ -128,7 +130,8 @@ def prepare_project(task: str = 'Any', collaborative_annotation=False):
             settings.ROLE_ANNOTATION_APPROVER,
             settings.ROLE_ANNOTATOR,
         ],
-        collaborative_annotation=collaborative_annotation
+        collaborative_annotation=collaborative_annotation,
+        **kwargs
     )
 
 
diff --git a/backend/api/tests/test_models.py b/backend/api/tests/test_models.py
--- a/backend/api/tests/test_models.py
+++ b/backend/api/tests/test_models.py
@@ -3,8 +3,9 @@
 from django.test import TestCase, override_settings
 from model_mommy import mommy
 
-from ..models import (Category, Label, Span, TextLabel,
+from ..models import (SEQUENCE_LABELING, Category, Label, Span, TextLabel,
                       generate_random_hex_color)
+from .api.utils import prepare_project
 
 
 @override_settings(STATICFILES_STORAGE='django.contrib.staticfiles.storage.StaticFilesStorage')
@@ -117,7 +118,12 @@ def test_uniqueness(self):
             Category(example=a.example, user=a.user, label=a.label).save()
 
 
-class TestSequenceAnnotation(TestCase):
+class TestSpan(TestCase):
+
+    def setUp(self):
+        self.project = prepare_project(SEQUENCE_LABELING, allow_overlapping=False)
+        self.example = mommy.make('Example', project=self.project.item)
+        self.user = self.project.users[0]
 
     def test_start_offset_is_not_negative(self):
         with self.assertRaises(IntegrityError):
@@ -131,33 +137,66 @@ def test_start_offset_is_less_than_end_offset(self):
         with self.assertRaises(IntegrityError):
             mommy.make('Span', start_offset=0, end_offset=0)
 
-    def test_overlapping(self):
-        project = mommy.make('SequenceLabelingProject', allow_overlapping=False)
-        example = mommy.make('Example', project=project)
-        mommy.make('Span', example=example, start_offset=5, end_offset=10)
-        with self.assertRaises(ValidationError):
-            mommy.make('Span', example=example, start_offset=5, end_offset=10)
-        with self.assertRaises(ValidationError):
-            mommy.make('Span', example=example, start_offset=5, end_offset=11)
-        with self.assertRaises(ValidationError):
-            mommy.make('Span', example=example, start_offset=4, end_offset=10)
-        with self.assertRaises(ValidationError):
-            mommy.make('Span', example=example, start_offset=6, end_offset=9)
-        with self.assertRaises(ValidationError):
-            mommy.make('Span', example=example, start_offset=9, end_offset=15)
-        with self.assertRaises(ValidationError):
-            mommy.make('Span', example=example, start_offset=0, end_offset=6)
-        mommy.make('Span', example=example, start_offset=0, end_offset=5)
-        mommy.make('Span', example=example, start_offset=10, end_offset=15)
+    def test_unique_constraint(self):
+        mommy.make('Span', example=self.example, start_offset=5, end_offset=10, user=self.user)
+        mommy.make('Span', example=self.example, start_offset=0, end_offset=5, user=self.user)
+        mommy.make('Span', example=self.example, start_offset=10, end_offset=15, user=self.user)
+
+    def test_unique_constraint_violated(self):
+        mommy.make('Span', example=self.example, start_offset=5, end_offset=10, user=self.user)
+        spans = [(5, 10), (5, 11), (4, 10), (6, 9), (9, 15), (0, 6)]
+        for start_offset, end_offset in spans:
+            with self.assertRaises(ValidationError):
+                mommy.make(
+                    'Span',
+                    example=self.example,
+                    start_offset=start_offset,
+                    end_offset=end_offset,
+                    user=self.user
+                )
+
+    def test_unique_constraint_if_overlapping_is_allowed(self):
+        project = prepare_project(SEQUENCE_LABELING, allow_overlapping=True)
+        example = mommy.make('Example', project=project.item)
+        user = project.users[0]
+        mommy.make('Span', example=example, start_offset=5, end_offset=10, user=user)
+        spans = [(5, 10), (5, 11), (4, 10), (6, 9), (9, 15), (0, 6)]
+        for start_offset, end_offset in spans:
+            mommy.make('Span', example=example, start_offset=start_offset, end_offset=end_offset, user=user)
 
     def test_update(self):
-        project = mommy.make('SequenceLabelingProject', allow_overlapping=False)
-        example = mommy.make('Example', project=project)
-        span = mommy.make('Span', example=example, start_offset=0, end_offset=5)
+        span = mommy.make('Span', example=self.example, start_offset=0, end_offset=5)
         span.end_offset = 6
         span.save()
 
 
+class TestSpanWithoutCollaborativeMode(TestCase):
+
+    def setUp(self):
+        self.project = prepare_project(SEQUENCE_LABELING, False, allow_overlapping=False)
+        self.example = mommy.make('Example', project=self.project.item)
+
+    def test_allow_users_to_create_same_spans(self):
+        mommy.make('Span', example=self.example, start_offset=5, end_offset=10, user=self.project.users[0])
+        mommy.make('Span', example=self.example, start_offset=5, end_offset=10, user=self.project.users[1])
+
+
+class TestSpanWithCollaborativeMode(TestCase):
+
+    def test_deny_users_to_create_same_spans(self):
+        project = prepare_project(SEQUENCE_LABELING, True, allow_overlapping=False)
+        example = mommy.make('Example', project=project.item)
+        mommy.make('Span', example=example, start_offset=5, end_offset=10, user=project.users[0])
+        with self.assertRaises(ValidationError):
+            mommy.make('Span', example=example, start_offset=5, end_offset=10, user=project.users[1])
+
+    def test_allow_users_to_create_same_spans_if_overlapping_is_allowed(self):
+        project = prepare_project(SEQUENCE_LABELING, True, allow_overlapping=True)
+        example = mommy.make('Example', project=project.item)
+        mommy.make('Span', example=example, start_offset=5, end_offset=10, user=project.users[0])
+        mommy.make('Span', example=example, start_offset=5, end_offset=10, user=project.users[1])
+
+
 class TestSeq2seqAnnotation(TestCase):
 
     def test_uniqueness(self):