From d22fa258e0323fe76bc0684a9defa83ae367e496 Mon Sep 17 00:00:00 2001 From: BHARATH0153 Date: Sat, 8 Aug 2026 09:11:37 +0530 Subject: [PATCH 1/9] [feature] Added REST API endpoints for organization memberships #543 Added dedicated REST API endpoints to manage user organization memberships: list/create under users/user/{id}/organization-membership/ and retrieve/update/delete under users/user/{id}/organization-membership/{org_id}/. Memberships can be managed by superusers and by organization managers of the organizations involved; managers are restricted to the organizations they manage and cannot manage superusers. Closes #543 --- docs/user/rest-api.rst | 47 ++++ openwisp_users/api/serializers.py | 40 ++++ openwisp_users/api/urls.py | 10 + openwisp_users/api/views.py | 69 ++++++ openwisp_users/tests/test_api/test_api.py | 240 +++++++++++++++++++- openwisp_users/tests/test_api/test_urls.py | 2 + openwisp_users/tests/test_api/test_views.py | 2 + 7 files changed, 409 insertions(+), 1 deletion(-) diff --git a/docs/user/rest-api.rst b/docs/user/rest-api.rst index d414538e7..70e942f69 100644 --- a/docs/user/rest-api.rst +++ b/docs/user/rest-api.rst @@ -203,6 +203,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 062ae0e8b..35407ee81 100644 --- a/openwisp_users/api/serializers.py +++ b/openwisp_users/api/serializers.py @@ -202,6 +202,46 @@ 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." + ) + } + ) + elif data.get("organization") is not None and ( + data["organization"].pk != self.instance.organization_id + ): + raise serializers.ValidationError( + { + "organization": _( + "Organization in the request body does not match the " + "organization in the URL." + ) + } + ) + return super().validate(data) + + class BaseSuperUserSerializer(ValidatedModelSerializer): _skip_validation_fields = [ "groups", diff --git a/openwisp_users/api/urls.py b/openwisp_users/api/urls.py index 980b8c799..393153c2c 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 1c9d8d8ba..54dc859b6 100644 --- a/openwisp_users/api/views.py +++ b/openwisp_users/api/views.py @@ -25,6 +25,7 @@ EmailAddressSerializer, GroupSerializer, OrganizationDetailSerializer, + OrganizationMembershipSerializer, OrganizationSerializer, SuperUserDetailSerializer, SuperUserListSerializer, @@ -252,6 +253,72 @@ 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): + return super().get_queryset().filter(user_id=self.kwargs["pk"]) + + +class OrganizationMembershipDetailView( + BaseOrganizationMembershipView, RetrieveUpdateDestroyAPIView +): + 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) + + obtain_auth_token = ObtainAuthTokenView.as_view() organization_list = OrganizationListCreateView.as_view() organization_detail = OrganizationDetailView.as_view() @@ -262,3 +329,5 @@ def get_object(self): change_password = ChangePasswordView.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 d06b52a34..965cd6554 100644 --- a/openwisp_users/tests/test_api/test_api.py +++ b/openwisp_users/tests/test_api/test_api.py @@ -11,7 +11,10 @@ from openwisp_utils.tests import AssertNumQueriesSubTestMixin -from ...api.serializers import OrganizationUserSerializer +from ...api.serializers import ( + OrganizationMembershipSerializer, + OrganizationUserSerializer, +) from ..utils import TestOrganizationMixin Organization = load_model("openwisp_users", "Organization") @@ -36,6 +39,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") @@ -504,6 +515,233 @@ def test_delete_email_api(self): self.assertEqual(response.status_code, 204) self.assertEqual(EmailAddress.objects.filter(user=user1).count(), 0) + # Tests for organization membership API endpoints + 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(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_put_org_mismatch_400_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) + path = reverse("users:organization_membership_detail", args=(user1.pk, org1.pk)) + data = {"organization": org2.pk, "is_admin": True} + with self.assertNumQueries(5): + r = self.client.put(path, data, content_type="application/json") + self.assertEqual(r.status_code, 400) + self.assertEqual( + r.data["organization"], + [ + "Organization in the request body does not match the " + "organization in the URL." + ], + ) + + 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_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.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 9437bc50c..c6c3e9262 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 bc81795bf..f7533dae7 100644 --- a/openwisp_users/tests/test_api/test_views.py +++ b/openwisp_users/tests/test_api/test_views.py @@ -32,6 +32,8 @@ 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/", ) for path in invalid_uuid_paths: From 37b3fea593469ebc1a1619abc7fe81e0ddaeebcf Mon Sep 17 00:00:00 2001 From: BHARATH0153 Date: Mon, 10 Aug 2026 08:21:08 +0530 Subject: [PATCH 2/9] [fix] Added membership user association assertion #543 Added an assertion verifying the created membership is associated with the user from the URL. Related to #543 Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- openwisp_users/tests/test_api/test_api.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openwisp_users/tests/test_api/test_api.py b/openwisp_users/tests/test_api/test_api.py index 965cd6554..d17c45a44 100644 --- a/openwisp_users/tests/test_api/test_api.py +++ b/openwisp_users/tests/test_api/test_api.py @@ -538,6 +538,9 @@ def test_organization_membership_post_api(self): 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"]) From 088d28767172ec9eb5184dd2feb7d817ee4868b3 Mon Sep 17 00:00:00 2001 From: BHARATH0153 Date: Mon, 10 Aug 2026 08:21:08 +0530 Subject: [PATCH 3/9] [fix] Added invalid org UUID test case for organization memberships #543 Added a test case with a valid user UUID and an invalid organization UUID so the organization UUID URL converter is exercised independently. Related to #543 --- openwisp_users/tests/test_api/test_views.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openwisp_users/tests/test_api/test_views.py b/openwisp_users/tests/test_api/test_views.py index f7533dae7..01513ecc0 100644 --- a/openwisp_users/tests/test_api/test_views.py +++ b/openwisp_users/tests/test_api/test_views.py @@ -34,6 +34,11 @@ def test_invalid_uuid_routes_return_404(self): "/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 path in invalid_uuid_paths: From 6a21b37e6d95e33da8dd31cbd6702cea864a9808 Mon Sep 17 00:00:00 2001 From: BHARATH0153 Date: Mon, 10 Aug 2026 08:26:22 +0530 Subject: [PATCH 4/9] [fix] Removed redundant test section comment #543 Related to #543 --- openwisp_users/tests/test_api/test_api.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openwisp_users/tests/test_api/test_api.py b/openwisp_users/tests/test_api/test_api.py index d17c45a44..42ec53990 100644 --- a/openwisp_users/tests/test_api/test_api.py +++ b/openwisp_users/tests/test_api/test_api.py @@ -515,7 +515,6 @@ def test_delete_email_api(self): self.assertEqual(response.status_code, 204) self.assertEqual(EmailAddress.objects.filter(user=user1).count(), 0) - # Tests for organization membership API endpoints def test_organization_membership_list_api(self): user1 = self._create_user(username="user1", email="user1@email.com") org1 = self._create_org(name="org1") From 9a0c2f6c917812624ba442f726c009750bc5ca69 Mon Sep 17 00:00:00 2001 From: BHARATH0153 Date: Fri, 14 Aug 2026 06:17:42 +0530 Subject: [PATCH 5/9] [fix] Fixed schema generation error in membership list endpoint #543 Schema generation sets swagger_fake_view on every view; the organization membership list view evaluated self.kwargs["pk"] anyway, raising KeyError during introspection. Related to #543 --- openwisp_users/api/views.py | 5 +++++ openwisp_users/tests/test_api/test_views.py | 9 +++++++++ 2 files changed, 14 insertions(+) diff --git a/openwisp_users/api/views.py b/openwisp_users/api/views.py index 3f2910257..f74b14603 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, @@ -377,6 +379,8 @@ class OrganizationMembershipListCreateView( 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"]) @@ -398,6 +402,7 @@ def update(self, request, *args, **kwargs): return super().update(request, *args, **kwargs) + obtain_auth_token = ObtainAuthTokenView.as_view() password_reset = PasswordResetView.as_view() password_reset_confirm = PasswordResetConfirmView.as_view() diff --git a/openwisp_users/tests/test_api/test_views.py b/openwisp_users/tests/test_api/test_views.py index 365d2b3ee..5e9c1fa16 100644 --- a/openwisp_users/tests/test_api/test_views.py +++ b/openwisp_users/tests/test_api/test_views.py @@ -84,6 +84,15 @@ 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): + # drf_yasg sets ``swagger_fake_view`` on every view while + # generating the schema; querysets must handle that without + # relying on URL kwargs. + 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) From 2f69eb488d11ffa1cf4c66d9cc193ba580651915 Mon Sep 17 00:00:00 2001 From: BHARATH0153 Date: Fri, 14 Aug 2026 06:17:51 +0530 Subject: [PATCH 6/9] [fix] Fixed deleting organization owner membership returning 500 #543 Deleting the membership of an organization owner raised organizations.exceptions.OwnershipRequired, returning 500. Catch it in the destroy path and respond with a 400 validation error. Related to #543 --- openwisp_users/api/views.py | 5 +++++ openwisp_users/tests/test_api/test_api.py | 12 ++++++++++++ openwisp_users/tests/test_api/test_views.py | 3 --- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/openwisp_users/api/views.py b/openwisp_users/api/views.py index f74b14603..942ec8800 100644 --- a/openwisp_users/api/views.py +++ b/openwisp_users/api/views.py @@ -401,6 +401,11 @@ 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() diff --git a/openwisp_users/tests/test_api/test_api.py b/openwisp_users/tests/test_api/test_api.py index 0187d8437..17e8f028d 100644 --- a/openwisp_users/tests/test_api/test_api.py +++ b/openwisp_users/tests/test_api/test_api.py @@ -655,6 +655,18 @@ def test_organization_membership_owner_downgrade_400_api(self): 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) diff --git a/openwisp_users/tests/test_api/test_views.py b/openwisp_users/tests/test_api/test_views.py index 5e9c1fa16..4fc074904 100644 --- a/openwisp_users/tests/test_api/test_views.py +++ b/openwisp_users/tests/test_api/test_views.py @@ -85,9 +85,6 @@ def test_invalid_uuid_routes_return_404(self): self.assertEqual(response.status_code, 404) def test_schema_generation_introspects_views(self): - # drf_yasg sets ``swagger_fake_view`` on every view while - # generating the schema; querysets must handle that without - # relying on URL kwargs. self._create_user(username="tester", password="tester") self.client.force_login(self._get_user("tester")) response = self.client.get(reverse("schema-json", args=[".json"])) From fb7b0fc5307c8a50978f04143e7b54be453730cd Mon Sep 17 00:00:00 2001 From: chandra Date: Fri, 14 Aug 2026 06:23:27 +0530 Subject: [PATCH 7/9] [fix] Added organization response assertion in manager create test #543 Related to #543 Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- openwisp_users/tests/test_api/test_api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openwisp_users/tests/test_api/test_api.py b/openwisp_users/tests/test_api/test_api.py index 17e8f028d..739835967 100644 --- a/openwisp_users/tests/test_api/test_api.py +++ b/openwisp_users/tests/test_api/test_api.py @@ -713,6 +713,7 @@ def test_organization_membership_manager_create_api(self): 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): From a4489e5a9281d20f687d555f922517da1e497a33 Mon Sep 17 00:00:00 2001 From: BHARATH0153 Date: Tue, 18 Aug 2026 06:21:36 +0530 Subject: [PATCH 8/9] [fix] Made organization read-only on membership detail endpoint #543 Removed the organization mismatch validation from OrganizationMembershipSerializer because it cannot occur in practice: the detail endpoint now returns organization as read-only, so clients cannot send a different organization value in the request body. Fixes #543 --- openwisp_users/api/serializers.py | 16 +++++----------- openwisp_users/api/views.py | 4 ++++ openwisp_users/tests/test_api/test_api.py | 18 ------------------ 3 files changed, 9 insertions(+), 29 deletions(-) diff --git a/openwisp_users/api/serializers.py b/openwisp_users/api/serializers.py index c2c47c3fb..4c8b6f545 100644 --- a/openwisp_users/api/serializers.py +++ b/openwisp_users/api/serializers.py @@ -239,20 +239,14 @@ def validate(self, data): ) } ) - elif data.get("organization") is not None and ( - data["organization"].pk != self.instance.organization_id - ): - raise serializers.ValidationError( - { - "organization": _( - "Organization in the request body does not match the " - "organization in the URL." - ) - } - ) 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/views.py b/openwisp_users/api/views.py index 942ec8800..96e7f3b0f 100644 --- a/openwisp_users/api/views.py +++ b/openwisp_users/api/views.py @@ -34,6 +34,7 @@ EmailAddressSerializer, GroupSerializer, OrganizationDetailSerializer, + OrganizationMembershipDetailSerializer, OrganizationMembershipSerializer, OrganizationSerializer, PasswordChangeSerializer, @@ -387,6 +388,9 @@ def get_queryset(self): 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"]) diff --git a/openwisp_users/tests/test_api/test_api.py b/openwisp_users/tests/test_api/test_api.py index 739835967..1b8e1c2f5 100644 --- a/openwisp_users/tests/test_api/test_api.py +++ b/openwisp_users/tests/test_api/test_api.py @@ -616,24 +616,6 @@ def test_organization_membership_patch_api(self): self.assertEqual(r.status_code, 200) self.assertTrue(r.data["is_admin"]) - def test_organization_membership_put_org_mismatch_400_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) - path = reverse("users:organization_membership_detail", args=(user1.pk, org1.pk)) - data = {"organization": org2.pk, "is_admin": True} - with self.assertNumQueries(5): - r = self.client.put(path, data, content_type="application/json") - self.assertEqual(r.status_code, 400) - self.assertEqual( - r.data["organization"], - [ - "Organization in the request body does not match the " - "organization in the URL." - ], - ) - def test_organization_membership_delete_api(self): user1 = self._create_user(username="user1", email="user1@email.com") org1 = self._create_org(name="org1") From 50a5348a36b3780acde81695022d02641fa81796 Mon Sep 17 00:00:00 2001 From: BHARATH0153 Date: Tue, 18 Aug 2026 06:48:18 +0530 Subject: [PATCH 9/9] [fix] Re-trigger CI checks #543 Fixes #543