Repository: akvo/akvo-rsr

Issue:
request_organisation use from Up creates multiple Employments
When posting to request_organisation from Up with the same data multiple times, multiple Employment objects are created. The reason for this looks to be that the unique_together on employment is org, user and group, and group is None when Up posts.

Since it makes sense to be able to add a user to the same org more than once in different roles (groups) one suggestion is to have an "Unassigned" group or similar that can be used until the employment is approved.



Discussion:

Resolving patch:
diff --git a/akvo/rest/views/user.py b/akvo/rest/views/user.py
--- a/akvo/rest/views/user.py
+++ b/akvo/rest/views/user.py
@@ -100,7 +100,7 @@ def request_organisation(request, pk=None):
     # request.user is the user identified by the auth token
     user = request.user
     # Users themselves are only allowed to request to join an organisation
-    if not user_by_pk == request.user:
+    if not user_by_pk == user:
         raise PermissionDenied()
     request.DATA['user'] = pk
 
@@ -109,14 +109,13 @@ def request_organisation(request, pk=None):
     if serializer.is_valid():
         try:
             organisation = Organisation.objects.get(pk=serializer.data['organisation'])
-            employment = Employment(
+            employment = Employment.objects.create(
                 user=user,
                 organisation=organisation,
                 country=serializer.data['country'],
                 job_title=serializer.data['job_title'],
                 is_approved=False,
             )
-            employment.save()
         except IntegrityError:
             return Response({'detail': _(u'User already linked to this organisation')},
                             status=status.HTTP_409_CONFLICT)
diff --git a/akvo/rsr/signals.py b/akvo/rsr/signals.py
--- a/akvo/rsr/signals.py
+++ b/akvo/rsr/signals.py
@@ -169,6 +169,9 @@ def act_on_log_entry(sender, **kwargs):
 def employment_pre_save(sender, **kwargs):
     """
     This signal intends to send a mail to the user when his/her account has been approved.
+
+    This signal also sets 'Users' Group for the employment if no group has been set
+
     A mail will be sent when:
 
     - A new employment is created with is_approved = True.
@@ -176,8 +179,13 @@ def employment_pre_save(sender, **kwargs):
     - An existing employment is updated from is_approved = False changed to True.
       * We assume this happens when an existing user has requested to join an organisation himself.
     """
+    # FIXME: The actual save may fail. Why are emails being sent pre_save?!
     employment = kwargs.get("instance", None)
 
+    # Set the group to 'Users' when no group has been specified
+    if not employment.group:
+        employment.group = Group.objects.get(name='Users')
+
     try:
         obj = sender.objects.get(pk=employment.pk)
     except sender.DoesNotExist:
@@ -218,67 +226,62 @@ def employment_post_save(sender, **kwargs):
     - Set User to is_staff (for admin access) when the employment is approved and the Group is set
       to 'Project Editors', 'User managers' or 'Admins', or when the user is a superuser or general
       admin.
-    - Set 'Users' Group for the employment if no group has been set
 
     If a new employment is created for an active user of which the employment is not approved yet:
     - Inform RSR support users, organisation admins and organisation user managers of the request
     """
     # Retrieve all user groups and the employment
-    users_group = Group.objects.get(name='Users')
     project_editors_group = Group.objects.get(name='Project Editors')
     user_managers_group = Group.objects.get(name='User Managers')
     admins_group = Group.objects.get(name='Admins')
     employment = kwargs.get("instance", None)
 
-    if employment:
-        user = employment.user
-
-        # Set user to staff when in a certain group
-        if (employment.group in [project_editors_group, user_managers_group, admins_group] and
-                employment.is_approved) or user.is_superuser or user.is_admin:
-            user.is_staff = True
-            user.save()
-
-        # Set the group to 'Users' when no group has been specified
-        if not employment.group:
-            employment.group = users_group
-            employment.save()
-
-        # Send an 'Organisation request' mail when an employment has been newly created, the
-        # user is active and the employment has not been approved yet.
-        if kwargs['created'] and user.is_active and not employment.is_approved:
-            organisation = employment.organisation
-
-            # Retrieve all active support users
-            active_support_users = get_user_model().objects.filter(is_active=True, is_support=True)
-
-            # General (support) admins will always be informed
-            admin_users = active_support_users.filter(is_admin=True)
-
-            # As well as organisation (+ content owners) User managers and Admins
-            employer_users = active_support_users.filter(
-                employers__organisation__in=organisation.content_owned_by(),
-                employers__group__in=[user_managers_group, admins_group]
-            )
-
-            notify = active_support_users.filter(
-                Q(pk__in=admin_users.values_list('pk', flat=True)) |
-                Q(pk__in=employer_users.values_list('pk', flat=True))
-            ).exclude(pk=user.pk).distinct()
-
-            rsr_send_mail_to_users(
-                notify,
-                subject='registration/user_organisation_request_subject.txt',
-                message='registration/user_organisation_request_message.txt',
-                subject_context={
-                    'user': user,
-                    'organisation': organisation
-                },
-                msg_context={
-                    'user': user,
-                    'organisation': organisation
-                },
-            )
+    if not employment:
+        return
+
+    user = employment.user
+
+    # Set user to staff when in a certain group
+    if (employment.group in [project_editors_group, user_managers_group, admins_group] and
+            employment.is_approved) or user.is_superuser or user.is_admin:
+        user.is_staff = True
+        user.save()
+
+    # Send an 'Organisation request' mail when an employment has been newly created, the
+    # user is active and the employment has not been approved yet.
+    if kwargs['created'] and user.is_active and not employment.is_approved:
+        organisation = employment.organisation
+
+        # Retrieve all active support users
+        active_support_users = get_user_model().objects.filter(is_active=True, is_support=True)
+
+        # General (support) admins will always be informed
+        admin_users = active_support_users.filter(is_admin=True)
+
+        # As well as organisation (+ content owners) User managers and Admins
+        employer_users = active_support_users.filter(
+            employers__organisation__in=organisation.content_owned_by(),
+            employers__group__in=[user_managers_group, admins_group]
+        )
+
+        notify = active_support_users.filter(
+            Q(pk__in=admin_users.values_list('pk', flat=True)) |
+            Q(pk__in=employer_users.values_list('pk', flat=True))
+        ).exclude(pk=user.pk).distinct()
+
+        rsr_send_mail_to_users(
+            notify,
+            subject='registration/user_organisation_request_subject.txt',
+            message='registration/user_organisation_request_message.txt',
+            subject_context={
+                'user': user,
+                'organisation': organisation
+            },
+            msg_context={
+                'user': user,
+                'organisation': organisation
+            },
+        )
 
 
 def update_project_budget(sender, **kwargs):


Test patch:
diff --git a/akvo/rsr/tests/rest/test_user.py b/akvo/rsr/tests/rest/test_user.py
new file mode 100644
--- /dev/null
+++ b/akvo/rsr/tests/rest/test_user.py
@@ -0,0 +1,72 @@
+# -*- coding: utf-8 -*-
+
+"""
+Akvo RSR is covered by the GNU Affero General Public License.
+
+See more details in the license.txt file located at the root folder of the Akvo RSR module.
+For additional details on the GNU license please see < http://www.gnu.org/licenses/agpl.html >.
+"""
+
+from __future__ import print_function
+
+from django.conf import settings
+from django.test import TransactionTestCase, Client
+
+from akvo.rsr.models import Employment, Organisation, User
+from akvo.utils import check_auth_groups
+
+
+# NOTE: Since these tests actually trigger some integrity errors and we want to
+# see if they are handled correctly, we use a TransactionTestCase instead of
+# TestCase, since wrapping the tests in a transaction isn't desirable.
+
+class UserTestCase(TransactionTestCase):
+    """Tests REST endpoints in views/user.py."""
+
+    def setUp(self):
+        check_auth_groups(settings.REQUIRED_AUTH_GROUPS)
+        self.c = Client(HTTP_HOST=settings.RSR_DOMAIN)
+        self.org = Organisation.objects.create(name='akvo', long_name='akvo foundation')
+        self.user_password = 'password'
+        self.user = self._create_user('abc@example.com', self.user_password)
+        self.c.login(username=self.user.username, password=self.user_password)
+
+    def test_request_organisation_once(self):
+        # Given
+        data = {'organisation': self.org.id}
+        pk = self.user.id
+
+        # When
+        response = self.c.post(
+            '/rest/v1/user/{}/request_organisation/?format=json'.format(pk), data
+        )
+
+        # Then
+        self.assertEqual(response.status_code, 200)
+        employment = Employment.objects.get(user=self.user, organisation_id=self.org.id)
+        self.assertEqual(employment.group.name, 'Users')
+
+    def test_request_organisation_twice(self):
+        # Given
+        data = {'organisation': self.org.id}
+        pk = self.user.id
+        self.c.post('/rest/v1/user/{}/request_organisation/?format=json'.format(pk), data)
+
+        # When
+        response = self.c.post(
+            '/rest/v1/user/{}/request_organisation/?format=json'.format(pk), data
+        )
+
+        # Then
+        self.assertEqual(response.status_code, 409)
+        employment = Employment.objects.get(user=self.user, organisation_id=self.org.id)
+        self.assertEqual(employment.group.name, 'Users')
+
+    def _create_user(self, email, password, is_active=True):
+        """Create a user with the given email and password."""
+
+        user = User.objects.create(email=email, username=email, is_active=is_active)
+        user.set_password(password)
+        user.save()
+
+        return user