diff --git a/docs/user/rest-api.rst b/docs/user/rest-api.rst index 3f9a7820..a015560f 100644 --- a/docs/user/rest-api.rst +++ b/docs/user/rest-api.rst @@ -272,6 +272,53 @@ Remove Email Address DELETE /api/v1/users/user/{id}/email/{id}/ +List Organization Memberships +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: text + + GET /api/v1/users/user/{id}/organization-membership/ + +Add Organization Membership +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: text + + POST /api/v1/users/user/{id}/organization-membership/ + +.. note:: + + The organization manager flag is represented internally by the + ``is_admin`` field in the payload. + +Get Organization Membership +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: text + + GET /api/v1/users/user/{id}/organization-membership/{org_id}/ + +Change Organization Membership +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: text + + PUT /api/v1/users/user/{id}/organization-membership/{org_id}/ + +Patch Organization Membership +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: text + + PATCH /api/v1/users/user/{id}/organization-membership/{org_id}/ + +Remove Organization Membership +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: text + + DELETE /api/v1/users/user/{id}/organization-membership/{org_id}/ + List Organizations ~~~~~~~~~~~~~~~~~~ diff --git a/openwisp_users/api/serializers.py b/openwisp_users/api/serializers.py index 2328f497..4c8b6f54 100644 --- a/openwisp_users/api/serializers.py +++ b/openwisp_users/api/serializers.py @@ -213,6 +213,40 @@ def to_internal_value(self, data): return super().to_internal_value(data) +class OrganizationMembershipSerializer(ValidatedModelSerializer): + exclude_validation = ("user", "organization") + id = serializers.UUIDField(read_only=True) + organization = OrgUserCustomPrimarykeyRelatedField() + + class Meta: + model = OrganizationUser + fields = ("id", "organization", "is_admin", "created", "modified") + + def validate(self, data): + data["user"] = self.context["user"] + if self.instance is None: + organization = data.get("organization") + if ( + organization is not None + and self.Meta.model.objects.filter( + user=data["user"], organization=organization + ).exists() + ): + raise serializers.ValidationError( + { + "organization": _( + "The user is already a member of this organization." + ) + } + ) + return super().validate(data) + + +class OrganizationMembershipDetailSerializer(OrganizationMembershipSerializer): + class Meta(OrganizationMembershipSerializer.Meta): + extra_kwargs = {"organization": {"read_only": True}} + + class BaseSuperUserSerializer(ValidatedModelSerializer): _skip_validation_fields = [ "groups", diff --git a/openwisp_users/api/urls.py b/openwisp_users/api/urls.py index a00bfbda..8225dd8b 100644 --- a/openwisp_users/api/urls.py +++ b/openwisp_users/api/urls.py @@ -46,6 +46,16 @@ def get_view(name): get_view("email_update"), name="email_update", ), + path( + "users/user//organization-membership/", + get_view("organization_membership_list"), + name="organization_membership_list", + ), + path( + "users/user//organization-membership//", + get_view("organization_membership_detail"), + name="organization_membership_detail", + ), path("users/group/", get_view("group_list"), name="group_list"), path("users/group//", get_view("group_detail"), name="group_detail"), ] diff --git a/openwisp_users/api/views.py b/openwisp_users/api/views.py index adb02b94..96e7f3b0 100644 --- a/openwisp_users/api/views.py +++ b/openwisp_users/api/views.py @@ -7,7 +7,9 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ from drf_yasg.utils import swagger_auto_schema +from organizations.exceptions import OwnershipRequired from rest_framework.authtoken.views import ObtainAuthToken +from rest_framework.exceptions import ValidationError from rest_framework.generics import ( GenericAPIView, ListCreateAPIView, @@ -32,6 +34,8 @@ EmailAddressSerializer, GroupSerializer, OrganizationDetailSerializer, + OrganizationMembershipDetailSerializer, + OrganizationMembershipSerializer, OrganizationSerializer, PasswordChangeSerializer, PasswordResetSerializer, @@ -331,6 +335,83 @@ def get_object(self): return obj +class BaseOrganizationMembershipView(ProtectedAPIMixin, FilterByParent, GenericAPIView): + model = OrganizationUser + serializer_class = OrganizationMembershipSerializer + + def get_queryset(self): + if getattr(self, "swagger_fake_view", False): + return OrganizationUser.objects.none() + qs = OrganizationUser.objects.select_related("organization", "user") + if not self.request.user.is_superuser: + qs = qs.filter(organization_id__in=self.request.user.organizations_managed) + return qs + + def initial(self, *args, **kwargs): + super().initial(*args, **kwargs) + self.assert_parent_exists() + + def get_parent_queryset(self): + qs = User.objects.filter(pk=self.kwargs["pk"]) + if self.request.user.is_superuser: + return qs + return self.get_organization_queryset(qs) + + def get_organization_queryset(self, qs): + orgs = self.request.user.organizations_managed + app_label = User._meta.app_config.label + filter_kwargs = { + "is_superuser": False, + f"{app_label}_organizationuser__organization_id__in": orgs, + } + return qs.filter(**filter_kwargs).distinct() + + def get_serializer_context(self): + if getattr(self, "swagger_fake_view", False): + return None + context = super().get_serializer_context() + context["user"] = self.get_parent_queryset().first() + return context + + +class OrganizationMembershipListCreateView( + BaseOrganizationMembershipView, ListCreateAPIView +): + pagination_class = OpenWispPagination + + def get_queryset(self): + if getattr(self, "swagger_fake_view", False): + return OrganizationUser.objects.none() + return super().get_queryset().filter(user_id=self.kwargs["pk"]) + + +class OrganizationMembershipDetailView( + BaseOrganizationMembershipView, RetrieveUpdateDestroyAPIView +): + def get_serializer_class(self): + return OrganizationMembershipDetailSerializer + + def get_object(self): + queryset = self.filter_queryset(self.get_queryset()) + queryset = queryset.filter(user_id=self.kwargs["pk"]) + filter_kwargs = { + "organization_id": self.kwargs["org_id"], + } + obj = get_object_or_404(queryset, **filter_kwargs) + self.check_object_permissions(self.request, obj) + return obj + + def update(self, request, *args, **kwargs): + kwargs["partial"] = True + return super().update(request, *args, **kwargs) + + def destroy(self, request, *args, **kwargs): + try: + return super().destroy(request, *args, **kwargs) + except OwnershipRequired as error: + raise ValidationError(str(error)) + + obtain_auth_token = ObtainAuthTokenView.as_view() password_reset = PasswordResetView.as_view() password_reset_confirm = PasswordResetConfirmView.as_view() @@ -344,3 +425,5 @@ def get_object(self): password_change = PasswordChangeView.as_view() email_update = EmailUpdateView.as_view() email_list = EmailListCreateView.as_view() +organization_membership_list = OrganizationMembershipListCreateView.as_view() +organization_membership_detail = OrganizationMembershipDetailView.as_view() diff --git a/openwisp_users/tests/test_api/test_api.py b/openwisp_users/tests/test_api/test_api.py index bf5768d4..1b8e1c2f 100644 --- a/openwisp_users/tests/test_api/test_api.py +++ b/openwisp_users/tests/test_api/test_api.py @@ -14,7 +14,10 @@ from openwisp_utils.tests import AssertNumQueriesSubTestMixin from ... import settings as app_settings -from ...api.serializers import OrganizationUserSerializer +from ...api.serializers import ( + OrganizationMembershipSerializer, + OrganizationUserSerializer, +) from ..utils import TestOrganizationMixin Organization = load_model("openwisp_users", "Organization") @@ -39,6 +42,14 @@ def test_organization_user_is_admin_label(self): self.assertEqual(list(serializer.fields), ["organization", "is_admin"]) self.assertEqual(serializer.fields["is_admin"].label, "Organization manager") + def test_organization_membership_serializer_fields(self): + serializer = OrganizationMembershipSerializer() + self.assertEqual( + list(serializer.fields), + ["id", "organization", "is_admin", "created", "modified"], + ) + self.assertFalse(serializer.fields["organization"].allow_null) + # Tests for Organization Model API endpoints def test_organization_list_api(self): path = reverse("users:organization_list") @@ -530,6 +541,230 @@ def test_delete_email_api(self): self.assertEqual(response.status_code, 204) self.assertEqual(EmailAddress.objects.filter(user=user1).count(), 0) + def test_organization_membership_list_api(self): + user1 = self._create_user(username="user1", email="user1@email.com") + org1 = self._create_org(name="org1") + org2 = self._create_org(name="org2") + self._create_org_user(user=user1, organization=org1) + self._create_org_user(user=user1, organization=org2) + path = reverse("users:organization_membership_list", args=(user1.pk,)) + with self.assertNumQueries(5): + r = self.client.get(path) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.data["count"], 2) + + def test_organization_membership_post_api(self): + user1 = self._create_user(username="user1", email="user1@email.com") + org1 = self._create_org(name="org1") + self.assertEqual(OrganizationUser.objects.count(), 0) + path = reverse("users:organization_membership_list", args=(user1.pk,)) + data = {"organization": org1.pk} + with self.assertNumQueries(9): + r = self.client.post(path, data, content_type="application/json") + self.assertEqual(r.status_code, 201) + self.assertEqual(OrganizationUser.objects.count(), 1) + self.assertEqual( + OrganizationUser.objects.get(organization=org1).user_id, user1.pk + ) + self.assertEqual(r.data["organization"], org1.pk) + self.assertFalse(r.data["is_admin"]) + + def test_organization_membership_post_duplicate_api(self): + user1 = self._create_user(username="user1", email="user1@email.com") + org1 = self._create_org(name="org1") + self._create_org_user(user=user1, organization=org1) + path = reverse("users:organization_membership_list", args=(user1.pk,)) + data = {"organization": org1.pk} + with self.assertNumQueries(5): + r = self.client.post(path, data, content_type="application/json") + self.assertEqual(r.status_code, 400) + self.assertEqual( + r.data["organization"], + ["The user is already a member of this organization."], + ) + + def test_organization_membership_detail_api(self): + user1 = self._create_user(username="user1", email="user1@email.com") + org1 = self._create_org(name="org1") + self._create_org_user(user=user1, organization=org1) + path = reverse("users:organization_membership_detail", args=(user1.pk, org1.pk)) + with self.assertNumQueries(4): + r = self.client.get(path) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.data["organization"], org1.pk) + self.assertFalse(r.data["is_admin"]) + + def test_organization_membership_put_api(self): + user1 = self._create_user(username="user1", email="user1@email.com") + org1 = self._create_org(name="org1") + self._create_org_user(user=user1, organization=org1) + path = reverse("users:organization_membership_detail", args=(user1.pk, org1.pk)) + data = {"is_admin": True} + with self.assertNumQueries(7): + r = self.client.put(path, data, content_type="application/json") + self.assertEqual(r.status_code, 200) + self.assertTrue(r.data["is_admin"]) + + def test_organization_membership_patch_api(self): + user1 = self._create_user(username="user1", email="user1@email.com") + org1 = self._create_org(name="org1") + self._create_org_user(user=user1, organization=org1) + path = reverse("users:organization_membership_detail", args=(user1.pk, org1.pk)) + data = {"is_admin": True} + with self.assertNumQueries(7): + r = self.client.patch(path, data, content_type="application/json") + self.assertEqual(r.status_code, 200) + self.assertTrue(r.data["is_admin"]) + + def test_organization_membership_delete_api(self): + user1 = self._create_user(username="user1", email="user1@email.com") + org1 = self._create_org(name="org1") + self._create_org_user(user=user1, organization=org1) + path = reverse("users:organization_membership_detail", args=(user1.pk, org1.pk)) + with self.assertNumQueries(7): + r = self.client.delete(path) + self.assertEqual(r.status_code, 204) + self.assertEqual(OrganizationUser.objects.count(), 0) + + def test_organization_membership_owner_downgrade_400_api(self): + user1 = self._create_user(username="user1", email="user1@email.com") + org1 = self._create_org(name="org1") + self._create_org_user(user=user1, organization=org1, is_admin=True) + self.assertTrue(user1.is_owner(org1)) + path = reverse("users:organization_membership_detail", args=(user1.pk, org1.pk)) + data = {"is_admin": False} + with self.assertNumQueries(4): + r = self.client.patch(path, data, content_type="application/json") + self.assertEqual(r.status_code, 400) + + def test_organization_membership_delete_owner_400_api(self): + user1 = self._create_user(username="user1", email="user1@email.com") + org1 = self._create_org(name="org1") + self._create_org_user(user=user1, organization=org1, is_admin=True) + self.assertTrue(user1.is_owner(org1)) + path = reverse("users:organization_membership_detail", args=(user1.pk, org1.pk)) + with self.assertNumQueries(5): + r = self.client.delete(path) + self.assertEqual(r.status_code, 400) + self.assertIn("Cannot delete organization owner", str(r.data[0])) + self.assertEqual(OrganizationUser.objects.count(), 1) + + def test_organization_membership_list_403_api(self): + user1 = self._create_user(username="user1", email="user1@email.com") + self.client.force_login(user1) + path = reverse("users:organization_membership_list", args=(user1.pk,)) + with self.assertNumQueries(4): + r = self.client.get(path) + self.assertEqual(r.status_code, 403) + + def test_organization_membership_manager_list_api(self): + org1 = self._create_org(name="org1") + org2 = self._create_org(name="org2") + org1_manager = self._create_user( + username="org1_manager", email="org1_manager@test.com" + ) + self._create_org_user(organization=org1, user=org1_manager, is_admin=True) + administrator = Group.objects.get(name="Administrator") + org1_manager.groups.add(administrator) + org1_user = self._create_user(username="org1_user", email="org1_user@test.com") + self._create_org_user(organization=org1, user=org1_user) + self._create_org_user(organization=org2, user=org1_user) + self.client.force_login(org1_manager) + path = reverse("users:organization_membership_list", args=(org1_user.pk,)) + with self.assertNumQueries(7): + r = self.client.get(path) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.data["count"], 1) + self.assertEqual(r.data["results"][0]["organization"], org1.pk) + + def test_organization_membership_manager_create_api(self): + org1 = self._create_org(name="org1") + org2 = self._create_org(name="org2") + org1_manager = self._create_user( + username="org1_manager", email="org1_manager@test.com" + ) + self._create_org_user(organization=org1, user=org1_manager, is_admin=True) + self._create_org_user(organization=org2, user=org1_manager, is_admin=True) + administrator = Group.objects.get(name="Administrator") + org1_manager.groups.add(administrator) + org1_user = self._create_user(username="org1_user", email="org1_user@test.com") + self._create_org_user(organization=org1, user=org1_user) + self.client.force_login(org1_manager) + path = reverse("users:organization_membership_list", args=(org1_user.pk,)) + data = {"organization": org2.pk, "is_admin": True} + with self.assertNumQueries(12): + r = self.client.post(path, data, content_type="application/json") + self.assertEqual(r.status_code, 201) + self.assertEqual(r.data["organization"], org2.pk) + self.assertTrue(r.data["is_admin"]) + + def test_organization_membership_manager_create_unmanaged_org_400_api(self): + org1 = self._create_org(name="org1") + org2 = self._create_org(name="org2") + org1_manager = self._create_user( + username="org1_manager", email="org1_manager@test.com" + ) + self._create_org_user(organization=org1, user=org1_manager, is_admin=True) + administrator = Group.objects.get(name="Administrator") + org1_manager.groups.add(administrator) + org1_user = self._create_user(username="org1_user", email="org1_user@test.com") + self._create_org_user(organization=org1, user=org1_user) + self.client.force_login(org1_manager) + path = reverse("users:organization_membership_list", args=(org1_user.pk,)) + data = {"organization": org2.pk} + with self.assertNumQueries(6): + r = self.client.post(path, data, content_type="application/json") + self.assertEqual(r.status_code, 400) + self.assertIn("organization", r.data) + + def test_organization_membership_manager_cross_org_404_api(self): + org1 = self._create_org(name="org1") + org2 = self._create_org(name="org2") + org1_manager = self._create_user( + username="org1_manager", email="org1_manager@test.com" + ) + self._create_org_user(organization=org1, user=org1_manager, is_admin=True) + administrator = Group.objects.get(name="Administrator") + org1_manager.groups.add(administrator) + org2_user = self._create_user(username="org2_user", email="org2_user@test.com") + self._create_org_user(organization=org2, user=org2_user) + self.client.force_login(org1_manager) + + with self.subTest("list memberships of a user outside managed organizations"): + path = reverse("users:organization_membership_list", args=(org2_user.pk,)) + with self.assertNumQueries(4): + r = self.client.get(path) + self.assertEqual(r.status_code, 404) + + with self.subTest("get membership of an unmanaged organization"): + org1_user = self._create_user( + username="org1_user", email="org1_user@test.com" + ) + self._create_org_user(organization=org1, user=org1_user) + self._create_org_user(organization=org2, user=org1_user) + path = reverse( + "users:organization_membership_detail", args=(org1_user.pk, org2.pk) + ) + with self.assertNumQueries(5): + r = self.client.get(path) + self.assertEqual(r.status_code, 404) + + def test_organization_membership_manager_cannot_manage_superuser_api(self): + org1 = self._create_org(name="org1") + org1_manager = self._create_user( + username="org1_manager", email="org1_manager@test.com" + ) + self._create_org_user(organization=org1, user=org1_manager, is_admin=True) + administrator = Group.objects.get(name="Administrator") + org1_manager.groups.add(administrator) + self._create_org_user(organization=org1, user=self._get_admin(), is_admin=False) + self.client.force_login(org1_manager) + admin = self._get_admin() + path = reverse("users:organization_membership_list", args=(admin.pk,)) + with self.assertNumQueries(4): + r = self.client.get(path) + self.assertEqual(r.status_code, 404) + # Tests for superuser's User API endpoints def test_get_user_list_api(self): path = reverse("users:user_list") diff --git a/openwisp_users/tests/test_api/test_urls.py b/openwisp_users/tests/test_api/test_urls.py index 9437bc50..c6c3e926 100644 --- a/openwisp_users/tests/test_api/test_urls.py +++ b/openwisp_users/tests/test_api/test_urls.py @@ -19,6 +19,8 @@ def custom_view(request): "change_password": "change_password", "email_list": "email_list", "email_update": "email_update", + "organization_membership_list": "organization_membership_list", + "organization_membership_detail": "organization_membership_detail", "group_list": "group_list", "group_detail": "group_detail", "user_auth_token": "obtain_auth_token", diff --git a/openwisp_users/tests/test_api/test_views.py b/openwisp_users/tests/test_api/test_views.py index eac3f6fe..4fc07490 100644 --- a/openwisp_users/tests/test_api/test_views.py +++ b/openwisp_users/tests/test_api/test_views.py @@ -70,6 +70,13 @@ def test_invalid_uuid_routes_return_404(self): "/api/v1/users/user/not-a-uuid/password/", "/api/v1/users/user/not-a-uuid/email/", "/api/v1/users/user/not-a-uuid/email/1/", + "/api/v1/users/user/not-a-uuid/organization-membership/", + "/api/v1/users/user/not-a-uuid/organization-membership/not-a-uuid/", + ( + "/api/v1/users/user/" + "0c09d8e3-9e1d-4d2b-9a8c-6f1b2c3d4e5f/" + "organization-membership/not-a-uuid/" + ), ) for url_path in invalid_uuid_paths: @@ -77,6 +84,12 @@ def test_invalid_uuid_routes_return_404(self): response = self.client.get(url_path) self.assertEqual(response.status_code, 404) + def test_schema_generation_introspects_views(self): + self._create_user(username="tester", password="tester") + self.client.force_login(self._get_user("tester")) + response = self.client.get(reverse("schema-json", args=[".json"])) + self.assertEqual(response.status_code, 200) + class TestGetApiUrls(TestCase): @patch.object(app_settings, "USERS_AUTH_API", False)