Repository: doccano/doccano

Issue:
[Bug report] Annotator doesn't see the project
How to reproduce the behaviour
---------
I saw a situation: user `a_user` is an `annotator` at `a_project`'s Members page; but `a_user` doesn't see `a_project` at his Projects page.

At that moment I've removed `a_user`'s role and added it again (since situation has been seen during my job process so I had to solve it). It helped.

But I reproduced it synthetically:
1. Add `a_user` as an `annotator` of `a_project`.
2. Sign in to Django Admin.
3. Go to `Projects` page.
4. Go to `a_project`'s page.
5. On `Users` field, deselect `a_user` item.
6. Click `Save`.

Now:  _user `a_user` is an `annotator` at `a_project`'s Members page; but `a_user` doesn't see `a_project` at his Projects page_.

**Questions:**
1. Where and how is list from p.5 stored?
2. How does result of pp.1-6 could be reached by interaction with doccano, what do you think?

Your Environment
---------
<!-- Include details of your environment.-->
*   Operating System: Ubuntu 18.04.4 LTS
*   Python Version Used: system Python is 3.6.9
*   When you install doccano: 4b5a3c34310f9c7e9d27163928073e74cc7bb226
*   How did you install doccano (Heroku button etc): Docker Compose


Discussion:
This may be due to the difference between RoleMapping and `users` field in the Project model.

Resolving patch:
diff --git a/app/api/migrations/0008_auto_20210302_1013.py b/app/api/migrations/0008_auto_20210302_1013.py
new file mode 100644
--- /dev/null
+++ b/app/api/migrations/0008_auto_20210302_1013.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.1.6 on 2021-03-02 10:13
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0007_auto_20210301_0302'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='project',
+            name='guideline',
+            field=models.TextField(blank=True, default=''),
+        ),
+    ]
diff --git a/app/api/models.py b/app/api/models.py
--- a/app/api/models.py
+++ b/app/api/models.py
@@ -5,7 +5,7 @@
 from django.contrib.auth.models import User
 from django.core.exceptions import ValidationError
 from django.db import models
-from django.db.models.signals import post_save, pre_delete
+from django.db.models.signals import m2m_changed, post_save, pre_delete
 from django.dispatch import receiver
 from django.urls import reverse
 from polymorphic.models import PolymorphicModel
@@ -28,7 +28,7 @@
 class Project(PolymorphicModel):
     name = models.CharField(max_length=100)
     description = models.TextField(default='')
-    guideline = models.TextField(default='')
+    guideline = models.TextField(default='', blank=True)
     created_at = models.DateTimeField(auto_now_add=True)
     updated_at = models.DateTimeField(auto_now=True)
     users = models.ManyToManyField(User, related_name='projects')
@@ -325,33 +325,48 @@ def add_linked_project(sender, instance, created, **kwargs):
         user.save()
 
 
-@receiver(post_save)
-def add_superusers_to_project(sender, instance, created, **kwargs):
-    if not created:
-        return
-    if sender not in Project.__subclasses__():
-        return
-    superusers = User.objects.filter(is_superuser=True)
-    admin_role = Role.objects.filter(name=settings.ROLE_PROJECT_ADMIN).first()
-    if superusers and admin_role:
+# @receiver(post_save)
+# def add_superusers_to_project(sender, instance, created, **kwargs):
+#     if not created:
+#         return
+#     if sender not in Project.__subclasses__():
+#         return
+#     superusers = User.objects.filter(is_superuser=True)
+#     admin_role = Role.objects.filter(name=settings.ROLE_PROJECT_ADMIN).first()
+#     if superusers and admin_role:
+#         RoleMapping.objects.bulk_create(
+#             [RoleMapping(role_id=admin_role.id, user_id=superuser.id, project_id=instance.id)
+#              for superuser in superusers]
+#         )
+#
+#
+# @receiver(post_save, sender=User)
+# def add_new_superuser_to_projects(sender, instance, created, **kwargs):
+#     if created and instance.is_superuser:
+#         admin_role = Role.objects.filter(name=settings.ROLE_PROJECT_ADMIN).first()
+#         projects = Project.objects.all()
+#         if admin_role and projects:
+#             RoleMapping.objects.bulk_create(
+#                 [RoleMapping(role_id=admin_role.id, user_id=instance.id, project_id=project.id)
+#                  for project in projects]
+#             )
+
+@receiver(m2m_changed, sender=Project.users.through)
+def remove_mapping_on_remove_user_from_project(sender, instance, action, reverse, **kwargs):
+    # if reverse is True, pk_set is project_ids and instance is user.
+    # else, pk_set is user_ids and instance is project.
+    user_ids = kwargs['pk_set']
+    if action.startswith('post_remove') and not reverse:
+        RoleMapping.objects.filter(user__in=user_ids, project=instance).delete()
+    elif action.startswith('post_add') and not reverse:
+        admin_role = Role.objects.get(name=settings.ROLE_PROJECT_ADMIN)
         RoleMapping.objects.bulk_create(
-            [RoleMapping(role_id=admin_role.id, user_id=superuser.id, project_id=instance.id)
-             for superuser in superusers]
+            [RoleMapping(role=admin_role, project=instance, user_id=user)
+             for user in user_ids
+             if not RoleMapping.objects.filter(project=instance, user_id=user).exists()]
         )
 
 
-@receiver(post_save, sender=User)
-def add_new_superuser_to_projects(sender, instance, created, **kwargs):
-    if created and instance.is_superuser:
-        admin_role = Role.objects.filter(name=settings.ROLE_PROJECT_ADMIN).first()
-        projects = Project.objects.all()
-        if admin_role and projects:
-            RoleMapping.objects.bulk_create(
-                [RoleMapping(role_id=admin_role.id, user_id=instance.id, project_id=project.id)
-                 for project in projects]
-            )
-
-
 @receiver(pre_delete, sender=RoleMapping)
 def delete_linked_project(sender, instance, using, **kwargs):
     userInstance = instance.user
diff --git a/app/api/views/project.py b/app/api/views/project.py
--- a/app/api/views/project.py
+++ b/app/api/views/project.py
@@ -1,7 +1,9 @@
-from rest_framework import generics
+from django.conf import settings
+from rest_framework import generics, status
 from rest_framework.permissions import IsAuthenticated
+from rest_framework.response import Response
 
-from ..models import Project
+from ..models import Project, Role, RoleMapping
 from ..permissions import IsInProjectReadOnlyOrAdmin
 from ..serializers import ProjectPolymorphicSerializer, ProjectSerializer
 
@@ -9,7 +11,7 @@
 class ProjectList(generics.ListCreateAPIView):
     serializer_class = ProjectPolymorphicSerializer
     pagination_class = None
-    permission_classes = [IsAuthenticated & IsInProjectReadOnlyOrAdmin]
+    permission_classes = [IsAuthenticated, ]
 
     def get_queryset(self):
         return self.request.user.projects
@@ -17,6 +19,20 @@ def get_queryset(self):
     def perform_create(self, serializer):
         serializer.save(users=[self.request.user])
 
+    def delete(self, request, *args, **kwargs):
+        delete_ids = request.data['ids']
+        projects = Project.objects.filter(
+            role_mappings__user=self.request.user,
+            role_mappings__role__name=settings.ROLE_PROJECT_ADMIN,
+            pk__in=delete_ids
+        )
+        # Todo: I want to use bulk delete.
+        # But it causes the constraint error.
+        # See https://github.com/django-polymorphic/django-polymorphic/issues/229
+        for project in projects:
+            project.delete()
+        return Response(status=status.HTTP_204_NO_CONTENT)
+
 
 class ProjectDetail(generics.RetrieveUpdateDestroyAPIView):
     queryset = Project.objects.all()


Test patch:
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
@@ -25,7 +25,13 @@ def create_default_roles():
 
 def assign_user_to_role(project_member, project, role_name):
     role, _ = Role.objects.get_or_create(name=role_name)
-    RoleMapping.objects.get_or_create(role_id=role.id, user_id=project_member.id, project_id=project.id)
+    if RoleMapping.objects.filter(user=project_member, project=project).exists():
+        mapping = RoleMapping.objects.get(user=project_member, project=project)
+        mapping.role = role
+        mapping.save()
+    else:
+        mapping = RoleMapping.objects.get_or_create(role_id=role.id, user_id=project_member.id, project_id=project.id)
+    return mapping
 
 
 def remove_all_role_mappings():
@@ -138,12 +144,6 @@ def test_allows_superuser_to_create_project_with_flags(self):
         self.assertTrue(response.json().get('collaborative_annotation'))
         self.assertTrue(response.json().get('randomize_document_order'))
 
-    def test_disallows_project_member_to_create_project(self):
-        self.client.login(username=self.main_project_member_name,
-                          password=self.main_project_member_pass)
-        response = self.client.post(self.url, format='json', data=self.data)
-        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
-
     @classmethod
     def doCleanups(cls):
         remove_all_role_mappings()
@@ -672,6 +672,7 @@ def setUpTestData(cls):
         cls.approver_pass = 'approver_pass'
         cls.project_admin_name = 'project_admin_name'
         cls.project_admin_pass = 'project_admin_pass'
+        create_default_roles()
         annotator = User.objects.create_user(username=cls.annotator_name,
                                              password=cls.annotator_pass)
         approver = User.objects.create_user(username=cls.approver_name,
@@ -681,7 +682,6 @@ def setUpTestData(cls):
         project = mommy.make('TextClassificationProject', users=[annotator, approver, project_admin])
         cls.doc = mommy.make('Document', project=project)
         cls.url = reverse(viewname='approve_labels', args=[project.id, cls.doc.id])
-        create_default_roles()
         assign_user_to_role(project_member=annotator, project=project,
                             role_name=settings.ROLE_ANNOTATOR)
         assign_user_to_role(project_member=approver, project=project,
@@ -1667,7 +1667,7 @@ def setUpTestData(cls):
                                               password=cls.other_user_pass,
                                               email='bar@buzz.com')
 
-        cls.project = mommy.make('TextClassificationProject', users=[super_user, other_user])
+        cls.project = mommy.make('TextClassificationProject', users=[super_user])
         doc1 = mommy.make('Document', project=cls.project)
         doc2 = mommy.make('Document', project=cls.project)
         mommy.make('DocumentAnnotation', document=doc1, user=super_user)
@@ -1807,7 +1807,7 @@ def setUpTestData(cls):
         cls.other_project = mommy.make('Project', users=[cls.second_project_member, project_admin])
         cls.admin_role = Role.objects.get(name=settings.ROLE_PROJECT_ADMIN)
         cls.role = mommy.make('Role', name='otherrole')
-        mommy.make('RoleMapping', role=cls.admin_role, project=cls.main_project, user=project_admin)
+        assign_user_to_role(project_admin, cls.main_project, cls.admin_role)
         cls.data = {'user': project_member.id, 'role': cls.admin_role.id, 'project': cls.main_project.id}
         cls.other_url = reverse(viewname='rolemapping_list', args=[cls.other_project.id])
         cls.url = reverse(viewname='rolemapping_list', args=[cls.main_project.id])
@@ -1818,23 +1818,24 @@ def test_returns_mappings_to_project_admin(self):
         response = self.client.get(self.url, format='json')
         self.assertEqual(response.status_code, status.HTTP_200_OK)
 
-    def test_allows_superuser_to_create_mapping(self):
-        self.client.login(username=self.project_admin_name,
-                          password=self.project_admin_pass)
-        response = self.client.post(self.url, format='json', data=self.data)
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-
-    def test_do_not_allow_nonadmin_to_create_mapping(self):
-        self.client.login(username=self.project_member_name,
-                          password=self.project_member_pass)
-        response = self.client.post(self.url, format='json', data=self.data)
-        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
-
-    def test_do_not_return_mappings_to_nonadmin(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_403_FORBIDDEN)
+    # Todo: refactoring testing.
+    # def test_allows_superuser_to_create_mapping(self):
+    #     self.client.login(username=self.project_admin_name,
+    #                       password=self.project_admin_pass)
+    #     response = self.client.post(self.url, format='json', data=self.data)
+    #     self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+    #
+    # def test_do_not_allow_nonadmin_to_create_mapping(self):
+    #     self.client.login(username=self.project_member_name,
+    #                       password=self.project_member_pass)
+    #     response = self.client.post(self.url, format='json', data=self.data)
+    #     self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+    #
+    # def test_do_not_return_mappings_to_nonadmin(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_403_FORBIDDEN)
 
 
 class TestRoleMappingDetailAPI(APITestCase):
@@ -1855,7 +1856,9 @@ def setUpTestData(cls):
         User.objects.create_user(username=cls.non_project_member_name, password=cls.non_project_member_pass)
         project = mommy.make('Project', users=[project_admin, project_member])
         admin_role = Role.objects.get(name=settings.ROLE_PROJECT_ADMIN)
-        cls.rolemapping = mommy.make('RoleMapping', role=admin_role, project=project, user=project_admin)
+        annotator_role = Role.objects.get(name=settings.ROLE_ANNOTATOR)
+        cls.rolemapping = assign_user_to_role(project_admin, project, admin_role)
+        assign_user_to_role(project_member, project, annotator_role)
         cls.url = reverse(viewname='rolemapping_detail', args=[project.id, cls.rolemapping.id])
         cls.data = {'role': admin_role.id}