Repository: akvo/akvo-rsr

Issue:
A4A project role M&E manager can add Enumerators



Discussion:

Resolving patch:
diff --git a/akvo/rest/serializers/project.py b/akvo/rest/serializers/project.py
--- a/akvo/rest/serializers/project.py
+++ b/akvo/rest/serializers/project.py
@@ -70,6 +70,7 @@ class ProjectSerializer(BaseRSRSerializer):
     can_publish = serializers.SerializerMethodField()
     can_edit_settings = serializers.SerializerMethodField()
     can_edit_access = serializers.SerializerMethodField()
+    can_edit_enumerator_access = serializers.SerializerMethodField()
     program = serializers.SerializerMethodField()
     targets_at = TargetsAtField(choices=Project.TARGETS_AT_OPTION, required=False)
 
@@ -109,6 +110,12 @@ def get_can_edit_access(self, obj):
             return False
         return user.can_edit_access(obj)
 
+    def get_can_edit_enumerator_access(self, obj):
+        user = self.context['request'].user
+        if not user.is_authenticated():
+            return False
+        return user.can_edit_enumerator_access(obj)
+
     def get_program(self, obj):
         program = obj.get_program()
         if not program:
diff --git a/akvo/rest/views/project_role.py b/akvo/rest/views/project_role.py
--- a/akvo/rest/views/project_role.py
+++ b/akvo/rest/views/project_role.py
@@ -29,30 +29,18 @@
 Role = namedtuple("Role", ("email", "role"))
 
 
-def is_reporting_org_admin(user, project):
-    reporting_org = project.reporting_org
-    if reporting_org is None:
-        return False
-
-    org_ids = {org.id for org in user.get_admin_employment_orgs()}
-    return reporting_org.pk in org_ids
-
-
 @api_view(["GET", "PATCH"])
 @login_required
 def project_roles(request, project_pk):
     user = request.user
     project = get_object_or_404(Project, pk=project_pk)
 
-    if not (
-        user.is_admin
-        or user.is_superuser
-        or is_reporting_org_admin(user, project)
-    ):
+    if not user.can_edit_enumerator_access(project):
         raise PermissionDenied
 
     status = 200
     if request.method == "PATCH":
+        manage_only_enumerators = not user.can_edit_access(project)
         roles = request.data.get("roles", [])
         auth_groups = {role["role"] for role in roles}
         unknown_groups = auth_groups - set(settings.REQUIRED_AUTH_GROUPS)
@@ -76,14 +64,27 @@ def project_roles(request, project_pk):
 
         groups = {name: Group.objects.get(name=name) for name in auth_groups}
         users = {email: User.objects.get(email=email) for email in emails}
-        new_roles = {Role(email=role['email'], role=role['role']) for role in roles}
+        new_roles = {
+            Role(email=role['email'], role=role['role'])
+            for role in (
+                [r for r in roles if r['role'] == 'Enumerators']
+                if manage_only_enumerators
+                else roles
+            )
+        }
         existing_roles = {
             Role(*role)
-            for role in project.projectrole_set.values_list(
-                "user__email", "group__name"
-            ).distinct()
+            for role in (
+                project.projectrole_set.filter(group__name="Enumerators")
+                if manage_only_enumerators
+                else project.projectrole_set
+            ).values_list("user__email", "group__name").distinct()
         }
-        use_project_roles = request.data.get('use_project_roles', False) or bool(new_roles)
+        use_project_roles = (
+            request.data.get('use_project_roles', False)
+            or bool(new_roles)
+            or (manage_only_enumerators and project.use_project_roles)
+        )
 
         with transaction.atomic():
             # Set use_project_roles flag on the project
@@ -142,7 +143,9 @@ def project_invite_user(request, project_pk):
     user = request.user
     project = get_object_or_404(Project, id=project_pk)
 
-    if not user.has_perm('rsr.user_management', project):
+    can_manage_users = user.has_perm('rsr.user_management', project)
+
+    if not can_manage_users and not user.can_edit_enumerator_access(project):
         return Response('Request not allowed', status=status.HTTP_403_FORBIDDEN)
 
     email, role, name = request.data.get('email'), request.data.get('role'), request.data.get('name')
@@ -152,8 +155,9 @@ def project_invite_user(request, project_pk):
 
     group = Group.objects.filter(name=role).first()
     if group is None:
-        return Response({'error': _('Role does not exist')},
-                        status=status.HTTP_400_BAD_REQUEST)
+        return Response({'error': _('Role does not exist')}, status=status.HTTP_400_BAD_REQUEST)
+    if not can_manage_users and group.name != 'Enumerators':
+        return Response('Request not allowed', status=status.HTTP_403_FORBIDDEN)
 
     invited_user = create_invited_user(email)
     project_role, __ = ProjectRole.objects.get_or_create(project=project, user=invited_user, group=group)
diff --git a/akvo/rsr/models/user.py b/akvo/rsr/models/user.py
--- a/akvo/rsr/models/user.py
+++ b/akvo/rsr/models/user.py
@@ -378,6 +378,16 @@ def can_edit_access(self, project):
                 and ProjectRole.objects.filter(project=project, user=self, group__name='Admins').exists())
         )
 
+    def can_edit_enumerator_access(self, project):
+        if self.can_edit_access(project):
+            return True
+        return (
+            project.use_project_roles
+            and project.reporting_org
+            and project.reporting_org.id in settings.ME_MANAGER_EDIT_ENUMERATOR_ACCESS_ORGS
+            and ProjectRole.objects.filter(project=project, user=self, group__name='M&E Managers').exists()
+        )
+
     def employments_dict(self, org_list):
         """
         Represent User as dict with employments.


Test patch:
diff --git a/akvo/rsr/tests/rest/test_project_role.py b/akvo/rsr/tests/rest/test_project_role.py
--- a/akvo/rsr/tests/rest/test_project_role.py
+++ b/akvo/rsr/tests/rest/test_project_role.py
@@ -8,6 +8,7 @@
 import json
 
 from django.contrib.auth.models import Group
+from django.test import override_settings
 from unittest.mock import patch
 
 from akvo.rsr.models import Partnership, ProjectRole
@@ -175,3 +176,197 @@ def test_project_invite_existing_user(self, mock_send):
         self.assertEqual(response_data['role']['role'], data['role'])
         self.assertEqual(response_data['role']['name'], user.get_full_name())
         mock_send.assert_not_called()
+
+
+class MEManagerEditEnumeratorAccessTestCase(BaseTestCase):
+    """Test M&E role can assign Enumerator to a project feature."""
+
+    ME_MANAGERS_ROLE = 'M&E Managers'
+    ENUMRATORS_ROLE = 'Enumerators'
+
+    def create_org_user(self, email, password, org, group_name='Users'):
+        user = self.create_user(email, password)
+        self.make_employment(user, org, group_name)
+        return user
+
+    def create_test_project(self, title, reporting_org):
+        project = self.create_project(title)
+        project.use_project_roles = True
+        project.save(update_fields=['use_project_roles'])
+        self.make_partner(
+            project, reporting_org, Partnership.IATI_REPORTING_ORGANISATION
+        )
+        return project
+
+    def make_project_role(self, project, user, group_name='Users'):
+        group = Group.objects.get(name=group_name)
+        ProjectRole.objects.create(user=user, project=project, group=group)
+
+    def test_me_project_role_of_configured_org_project_should_be_able_to_get_project_roles(self):
+        org = self.create_organisation('Acme')
+        user = self.create_org_user('test@acme.org', 'password', org)
+        project = self.create_test_project('Project #1', org)
+        self.make_project_role(project, user, self.ME_MANAGERS_ROLE)
+
+        self.c.login(username=user.email, password='password')
+        with override_settings(ME_MANAGER_EDIT_ENUMERATOR_ACCESS_ORGS=[org.id]):
+            response = self.c.get(
+                "/rest/v1/project/{}/project-roles/?format=json".format(project.pk)
+            )
+
+        self.assertEqual(response.status_code, 200)
+        data = response.data
+        self.assertEqual(data["organisations"], [])
+        self.assertEqual(len(data["roles"]), 1)
+        self.assertEqual(data["roles"][0]["email"], user.email)
+        self.assertEqual(data["roles"][0]["role"], self.ME_MANAGERS_ROLE)
+
+    def test_me_project_role_of_non_configured_org_project_should_not_be_able_to_get_project_roles(self):
+        org = self.create_organisation('Acme')
+        user = self.create_org_user('test@acme.org', 'password', org)
+        project = self.create_test_project('Project #1', org)
+        self.make_project_role(project, user, self.ME_MANAGERS_ROLE)
+
+        self.c.login(username=user.email, password='password')
+        with override_settings(ME_MANAGER_EDIT_ENUMERATOR_ACCESS_ORGS=[]):
+            response = self.c.get(
+                "/rest/v1/project/{}/project-roles/?format=json".format(project.pk)
+            )
+
+        self.assertEqual(response.status_code, 403)
+
+    def test_me_project_role_should_be_able_to_add_new_enumerator(self):
+        org = self.create_organisation('Acme')
+        me_manager = self.create_org_user('test@acme.org', 'password', org)
+        project = self.create_test_project('Project #1', org)
+        self.make_project_role(project, me_manager, self.ME_MANAGERS_ROLE)
+        new_user = self.create_user('new-user@example.org', "password")
+
+        self.c.login(username=me_manager.email, password='password')
+        with override_settings(ME_MANAGER_EDIT_ENUMERATOR_ACCESS_ORGS=[org.id]):
+            response = self.c.patch(
+                "/rest/v1/project/{}/project-roles/?format=json".format(project.pk),
+                data=json.dumps({"roles": [
+                    {"email": me_manager.email, "role": self.ME_MANAGERS_ROLE},
+                    {"email": new_user.email, "role": self.ENUMRATORS_ROLE},
+                ]}),
+                content_type="application/json",
+            )
+
+        self.assertEqual(response.status_code, 200)
+        data = response.data
+        self.assertEqual(len(data['roles']), 2)
+        new_role = [r for r in data['roles'] if r['email'] == new_user.email][0]
+        self.assertEqual(new_role['role'], self.ENUMRATORS_ROLE)
+
+    def test_me_project_role_adding_non_enumerator_role_should_be_ignored(self):
+        org = self.create_organisation('Acme')
+        me_manager = self.create_org_user('test@acme.org', 'password', org)
+        project = self.create_test_project('Project #1', org)
+        self.make_project_role(project, me_manager, self.ME_MANAGERS_ROLE)
+        new_user = self.create_user('new-user@example.org', "password")
+
+        self.c.login(username=me_manager.email, password='password')
+        with override_settings(ME_MANAGER_EDIT_ENUMERATOR_ACCESS_ORGS=[org.id]):
+            response = self.c.patch(
+                "/rest/v1/project/{}/project-roles/?format=json".format(project.pk),
+                data=json.dumps({"roles": [
+                    {"email": me_manager.email, "role": self.ME_MANAGERS_ROLE},
+                    {"email": new_user.email, "role": self.ME_MANAGERS_ROLE},
+                ]}),
+                content_type="application/json",
+            )
+
+        self.assertEqual(response.status_code, 200)
+        data = response.data
+        self.assertEqual(len(data['roles']), 1)
+        self.assertEqual(data["roles"][0]["email"], me_manager.email)
+        self.assertEqual(data["roles"][0]["role"], self.ME_MANAGERS_ROLE)
+
+    def test_me_manager_role_should_be_able_to_remove_enumerator_role(self):
+        org = self.create_organisation('Acme')
+        me_manager = self.create_org_user('test@acme.org', 'password', org)
+        project = self.create_test_project('Project #1', org)
+        self.make_project_role(project, me_manager, self.ME_MANAGERS_ROLE)
+        enumerator = self.create_user('new-user@example.org', "password")
+        self.make_project_role(project, enumerator, self.ENUMRATORS_ROLE)
+
+        self.c.login(username=me_manager.email, password='password')
+        with override_settings(ME_MANAGER_EDIT_ENUMERATOR_ACCESS_ORGS=[org.id]):
+            response = self.c.patch(
+                "/rest/v1/project/{}/project-roles/?format=json".format(project.pk),
+                data=json.dumps({"roles": [
+                    {"email": me_manager.email, "role": self.ME_MANAGERS_ROLE},
+                ]}),
+                content_type="application/json",
+            )
+
+        self.assertEqual(response.status_code, 200)
+        data = response.data
+        self.assertEqual(len(data['roles']), 1)
+        self.assertEqual(data["roles"][0]["email"], me_manager.email)
+        self.assertEqual(data["roles"][0]["role"], self.ME_MANAGERS_ROLE)
+
+    def test_me_project_role_removing_no_enumerator_role_should_be_ignored(self):
+        org = self.create_organisation('Acme')
+        me_manager = self.create_org_user('test@acme.org', 'password', org)
+        project = self.create_test_project('Project #1', org)
+        self.make_project_role(project, me_manager, self.ME_MANAGERS_ROLE)
+        enumerator = self.create_user('new-user@example.org', "password")
+        self.make_project_role(project, enumerator, self.ME_MANAGERS_ROLE)
+
+        self.c.login(username=me_manager.email, password='password')
+        with override_settings(ME_MANAGER_EDIT_ENUMERATOR_ACCESS_ORGS=[org.id]):
+            response = self.c.patch(
+                "/rest/v1/project/{}/project-roles/?format=json".format(project.pk),
+                data=json.dumps({"roles": [
+                    {"email": me_manager.email, "role": self.ME_MANAGERS_ROLE},
+                ]}),
+                content_type="application/json",
+            )
+
+        self.assertEqual(response.status_code, 200)
+        data = response.data
+        self.assertEqual(len(data['roles']), 2)
+
+    @patch.object(utils, 'rsr_send_mail')
+    def test_me_project_role_should_be_able_to_invite_new_enumerator(self, mock_send):
+        org = self.create_organisation('Acme')
+        me_manager = self.create_org_user('test@acme.org', 'password', org)
+        project = self.create_test_project('Project #1', org)
+        self.make_project_role(project, me_manager, self.ME_MANAGERS_ROLE)
+        enumerator_email = "test@example.org"
+        enumerator_name = "John Doe"
+
+        self.c.login(username=me_manager.email, password='password')
+        with override_settings(ME_MANAGER_EDIT_ENUMERATOR_ACCESS_ORGS=[org.id]):
+            response = self.c.post(
+                "/rest/v1/project/{}/invite-user/?format=json".format(project.pk),
+                data=json.dumps({"email": enumerator_email, "role": self.ENUMRATORS_ROLE, "name": enumerator_name}),
+                content_type="application/json",
+            )
+
+        self.assertEqual(response.status_code, 201)
+        response_data = response.data
+        self.assertEqual(response_data['role']['email'], enumerator_email)
+        self.assertEqual(response_data['role']['role'], self.ENUMRATORS_ROLE)
+        self.assertEqual(response_data['role']['name'], enumerator_name)
+        mock_send.assert_called_once()
+
+    def test_me_project_role_should_not_be_able_to_invite_non_enumerator_role(self):
+        org = self.create_organisation('Acme')
+        me_manager = self.create_org_user('test@acme.org', 'password', org)
+        project = self.create_test_project('Project #1', org)
+        self.make_project_role(project, me_manager, self.ME_MANAGERS_ROLE)
+        enumerator_email = "test@example.org"
+        enumerator_name = "John Doe"
+
+        self.c.login(username=me_manager.email, password='password')
+        with override_settings(ME_MANAGER_EDIT_ENUMERATOR_ACCESS_ORGS=[org.id]):
+            response = self.c.post(
+                "/rest/v1/project/{}/invite-user/?format=json".format(project.pk),
+                data=json.dumps({"email": enumerator_email, "role": self.ME_MANAGERS_ROLE, "name": enumerator_name}),
+                content_type="application/json",
+            )
+
+        self.assertEqual(response.status_code, 403)