From f5265760376a5667ba93345868fa43358a23613c Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Mon, 13 Jul 2026 19:15:44 +0530 Subject: [PATCH 01/34] [feature] Made disabled organizations readonly but deletable #522 Made disabled organizations read-only but deletable: objects belonging to a disabled organization can still be viewed and deleted, but not created or modified, in both the admin interface and the REST API. This also applies to the organization record itself (only re-enabling or unassigning its owner is allowed while disabled). Organization selection widgets exclude disabled organizations, while admin list filters keep them visible for auditing. Closes #522 --- docs/developer/admin-utils.rst | 26 +++ .../developer/django-rest-framework-utils.rst | 54 ++++- docs/user/basic-concepts.rst | 36 ++++ openwisp_users/admin.py | 46 ++++- openwisp_users/api/mixins.py | 33 ++- openwisp_users/api/permissions.py | 27 ++- openwisp_users/api/serializers.py | 64 ++++-- openwisp_users/apps.py | 2 +- openwisp_users/base/models.py | 20 +- openwisp_users/multitenancy.py | 20 ++ openwisp_users/tests/test_admin.py | 193 ++++++++++++++++++ openwisp_users/tests/test_api/test_api.py | 108 +++++++++- openwisp_users/tests/test_models.py | 87 ++++++++ openwisp_users/views.py | 4 +- openwisp_users/widgets.py | 2 +- tests/testapp/tests/test_multitenancy.py | 28 +++ .../testapp/tests/test_permission_classes.py | 100 +++++++++ tests/testapp/tests/test_selenium.py | 29 ++- tests/testapp/tests/test_views.py | 19 ++ tests/testapp/urls.py | 10 + tests/testapp/views.py | 25 +++ 21 files changed, 872 insertions(+), 61 deletions(-) diff --git a/docs/developer/admin-utils.rst b/docs/developer/admin-utils.rst index ea7516c0d..30ae19e03 100644 --- a/docs/developer/admin-utils.rst +++ b/docs/developer/admin-utils.rst @@ -31,6 +31,32 @@ This class has two important attributes: `_ for a real-world example. +Disabled Organization Write Protection +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``MultitenantAdminMixin`` also blocks changes to any object belonging to a +:ref:`disabled organization `, while still +allowing that object to be viewed and deleted. This applies to superusers +too: there is no bypass. + +This is controlled by the ``disabled_organization_write_protection`` class +attribute, which defaults to ``True``. Set it to ``False`` on a specific +``ModelAdmin`` to opt out: + +.. code-block:: python + + from django.contrib import admin + from openwisp_users.multitenancy import MultitenantAdminMixin + + + class BookAdmin(MultitenantAdminMixin, admin.ModelAdmin): + disabled_organization_write_protection = False + # other attributes + +The ``organization`` form field's queryset also excludes disabled +organizations for everyone, superusers included, so a disabled +organization can never be *selected* for a new or existing object either. + ``MultitenantOrgFilter`` ------------------------ diff --git a/docs/developer/django-rest-framework-utils.rst b/docs/developer/django-rest-framework-utils.rst index 7ccc729e3..f1693ecd4 100644 --- a/docs/developer/django-rest-framework-utils.rst +++ b/docs/developer/django-rest-framework-utils.rst @@ -131,13 +131,56 @@ organization managers or owners to view shared objects in read-only mode. Standard users will not be able to view or list shared objects. +``DisabledOrgReadOnly`` +~~~~~~~~~~~~~~~~~~~~~~~ + +**Full python path**: +``openwisp_users.api.permissions.DisabledOrgReadOnly``. + +This object-level permission class blocks updating an object that belongs +to a :ref:`disabled organization `. Read (safe +methods) and ``DELETE`` remain allowed. + +A view can opt out of this guard by setting +``allow_disabled_organization_writes = True``: + +.. code-block:: python + + from openwisp_users.api.permissions import DisabledOrgReadOnly + from rest_framework.generics import RetrieveUpdateDestroyAPIView + + + class SubnetView(RetrieveUpdateDestroyAPIView): + permission_classes = (DisabledOrgReadOnly,) + allow_disabled_organization_writes = True + # other attributes + +``DisabledOrgReadOnly`` is already included in ``ProtectedAPIMixin``'s +default ``permission_classes`` (see below), so views that use +``ProtectedAPIMixin`` get this guard automatically without any extra +configuration. + +.. note:: + + ``Organization.active`` (django-organizations' ``ActiveOrgManager``) + is the canonical queryset for active organizations: use + ``Organization.active.all()`` when writing custom code that needs to + select from or filter active organizations, instead of filtering + ``Organization.objects`` manually. + ``ProtectedAPIMixin`` --------------------- **Full python path**: ``openwisp_users.api.mixins.ProtectedAPIMixin``. This mixin provides a set of authentication and permission classes that -are commonly used across various OpenWISP modules API views. +are commonly used across various OpenWISP modules API views, including +``DisabledOrgReadOnly`` (see above). + +If a view overrides ``permission_classes`` entirely instead of extending +``ProtectedAPIMixin.permission_classes``, it will not inherit +``DisabledOrgReadOnly`` (or any future addition to the mixin's defaults) +automatically, and must re-declare it explicitly if the guard is needed. Usage example: @@ -255,6 +298,15 @@ and ``FilterSerializerByOrgOwned`` can be used to solve this issue. These serializers do not allow non-superusers to create shared objects. +.. _multi_tenant_serializers_disabled_org: + +The ``organization`` field's queryset also excludes :ref:`disabled +organizations `, for everyone, superusers +included, so a disabled organization can never be selected when creating +or updating an object. Submitting the primary key of a disabled +organization returns a validation error explaining that the organization +does not exist or is disabled. + Usage example: .. code-block:: python diff --git a/docs/user/basic-concepts.rst b/docs/user/basic-concepts.rst index f41d28126..d7c4001a4 100644 --- a/docs/user/basic-concepts.rst +++ b/docs/user/basic-concepts.rst @@ -149,6 +149,42 @@ instance of the platform. `django-organizations `_ third-party app. +.. _disabling_an_organization: + +Disabling an Organization +------------------------- + +Superusers and managers of the organization can disable it, by unchecking +its **Is active** flag on the "Change organization" page or via the REST +API (subject to the usual permission requirements for editing an +organization). + +Disabling an organization does not delete anything: all of its data, +including users, memberships, and related objects, remains fully +**readable** and **deletable**. What changes is: + +- **No new object can be created for a disabled organization**, and + **existing objects belonging to it cannot be modified**, superusers + included. This applies to the organization's own record too: once + disabled, only its **Is active** flag can be changed (to re-enable it) + or its owner unassigned; everything else is locked until it is + re-enabled. +- Deleting objects, including the organization itself, is always allowed, + so cleanup is never blocked. +- The organization stops appearing in **organization selection widgets** + (e.g. when creating a new object), so it can no longer be picked for new + data. It still appears in admin **list filters**, so its existing data + remains easy to find for auditing purposes. +- Re-enabling a disabled organization is allowed for the same users who + can disable it: superusers and managers of that organization. + +.. note:: + + In the REST API, attempting to update an object belonging to a + disabled organization returns an HTTP 400 or 403 response with a clear + error message, instead of failing silently or being blocked without + explanation. + Organization Membership and Roles --------------------------------- diff --git a/openwisp_users/admin.py b/openwisp_users/admin.py index 453589a68..05081b9cf 100644 --- a/openwisp_users/admin.py +++ b/openwisp_users/admin.py @@ -98,7 +98,15 @@ class OrganizationOwnerInline(admin.StackedInline): extra = 0 autocomplete_fields = ("organization_user",) + def has_add_permission(self, request, obj=None): + # obj is the parent Organization here + if obj is not None and not obj.is_active: + return False + return super().has_add_permission(request, obj) + def has_change_permission(self, request, obj=None): + if obj is not None and not obj.is_active: + return False if obj and not request.user.is_superuser and not request.user.is_owner(obj): return False return super().has_change_permission(request, obj) @@ -113,17 +121,18 @@ class OrganizationUserInline(admin.StackedInline): def get_formset(self, request, obj=None, **kwargs): """ - In form dropdowns, display only organizations - in which operator `is_admin` and for superusers - display all organizations + In form dropdowns, display only active organizations; + non-superusers additionally only see organizations + in which they are `is_admin`. """ formset = super().get_formset(request, obj=obj, **kwargs) + org_field = formset.form.base_fields["organization"] + org_field.queryset = org_field.queryset.filter(is_active=True) if request.user.is_superuser: return formset - if not request.user.is_superuser: - formset.form.base_fields["organization"].queryset = ( - Organization.objects.filter(pk__in=request.user.organizations_managed) - ) + org_field.queryset = org_field.queryset.filter( + pk__in=request.user.organizations_managed + ) return formset def get_extra(self, request, obj=None, **kwargs): @@ -584,6 +593,29 @@ def has_change_permission(self, request, obj=None): return False return super().has_change_permission(request, obj) + def get_readonly_fields(self, request, obj=None): + """ + A disabled organization can only be re-enabled: every other + field becomes readonly (owner unassignment is still possible + via the inline's delete action, which does not go through here). + """ + fields = super().get_readonly_fields(request, obj) + if obj and not obj.is_active: + editable_fields = [ + f.name + for f in self.model._meta.local_fields + if f.editable and f.name != "is_active" + ] + fields = list(fields) + [f for f in editable_fields if f not in fields] + return fields + + def get_prepopulated_fields(self, request, obj=None): + # prepopulated_fields cannot reference a field that is also + # readonly, which is the case for "slug" on a disabled organization + if obj and not obj.is_active: + return {} + return super().get_prepopulated_fields(request, obj) + class Media(CopyableFieldsAdmin.Media): css = {"all": ("openwisp-users/css/admin.css",)} diff --git a/openwisp_users/api/mixins.py b/openwisp_users/api/mixins.py index 0eb32756f..ea23dcc91 100644 --- a/openwisp_users/api/mixins.py +++ b/openwisp_users/api/mixins.py @@ -1,6 +1,7 @@ import swapper from django.core.exceptions import ValidationError from django.db.models import ForeignKey, ManyToManyField, Q +from django.utils.translation import gettext_lazy as _ from django_filters import rest_framework as filters from django_filters.filters import QuerySetRequestMixin as BaseQuerySetRequestMixin from rest_framework.authentication import SessionAuthentication @@ -8,7 +9,11 @@ from rest_framework.permissions import IsAuthenticated from .authentication import BearerAuthentication -from .permissions import DjangoModelPermissions, IsOrganizationManager +from .permissions import ( + DisabledOrgReadOnly, + DjangoModelPermissions, + IsOrganizationManager, +) Organization = swapper.load_model("openwisp_users", "Organization") @@ -159,18 +164,27 @@ def _user_attr(self): def filter_fields(self): user = self.context["request"].user - # superuser can see everything - if user.is_superuser or user.is_anonymous: - return - # non superusers can see only items of organizations they're related to - organization_filter = getattr(user, self._user_attr) + # superuser can see everything, except disabled organizations + superuser = user.is_superuser or user.is_anonymous + if not superuser: + # non superusers can see only items of organizations + # they're related to + organization_filter = getattr(user, self._user_attr) for field in self.fields: if field == "organization" and not self.fields[field].read_only: # queryset attribute will not be present if set to read_only - self.fields[field].allow_null = False - self.fields[field].queryset = self.fields[field].queryset.filter( - pk__in=organization_filter + # disabled organizations are excluded for everyone, superusers + # included, since they can only be re-enabled, not written to + queryset = self.fields[field].queryset.filter(is_active=True) + self.fields[field].error_messages["does_not_exist"] = _( + 'Organization with pk "{pk_value}" does not exist or is disabled.' ) + if not superuser: + self.fields[field].allow_null = False + queryset = queryset.filter(pk__in=organization_filter) + self.fields[field].queryset = queryset + continue + if superuser: continue conditions = Q(**{self.organization_lookup: organization_filter}) if self.include_shared: @@ -308,4 +322,5 @@ class ProtectedAPIMixin(object): permission_classes = ( IsOrganizationManager, DjangoModelPermissions, + DisabledOrgReadOnly, ) diff --git a/openwisp_users/api/permissions.py b/openwisp_users/api/permissions.py index 4177229f3..af5b7389f 100644 --- a/openwisp_users/api/permissions.py +++ b/openwisp_users/api/permissions.py @@ -1,5 +1,5 @@ from django.utils.translation import gettext_lazy as _ -from rest_framework.permissions import BasePermission +from rest_framework.permissions import SAFE_METHODS, BasePermission from rest_framework.permissions import ( DjangoModelPermissions as BaseDjangoModelPermissions, ) @@ -95,6 +95,31 @@ def validate_membership(self, user, org): return org and (user.is_superuser or user.is_owner(org)) +class DisabledOrgReadOnly(ObjectOrganizationMixin, BasePermission): + """ + Blocks update of objects belonging to a disabled organization. + Read and delete remain allowed. Applies to superusers as well. + Views can opt out with `allow_disabled_organization_writes = True`. + """ + + message = _( + "This object belongs to a disabled organization: " + "it can be viewed or deleted, but not modified." + ) + + def has_object_permission(self, request, view, obj): + if getattr(view, "allow_disabled_organization_writes", False): + return True + if request.method in SAFE_METHODS or request.method == "DELETE": + return True + try: + organization = self.get_object_organization(view, obj) + except AttributeError: + # object has no organization field, rule not applicable + return True + return organization is None or organization.is_active + + class DjangoModelPermissions(ObjectOrganizationMixin, BaseDjangoModelPermissions): perms_map = { "GET": ["%(app_label)s.view_%(model_name)s"], diff --git a/openwisp_users/api/serializers.py b/openwisp_users/api/serializers.py index 2328f4971..dfa2e97ca 100644 --- a/openwisp_users/api/serializers.py +++ b/openwisp_users/api/serializers.py @@ -12,6 +12,7 @@ from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission from django.contrib.sites.shortcuts import get_current_site +from django.core.exceptions import ValidationError as DjangoValidationError from django.db import transaction from django.db.models import Q from django.utils.module_loading import import_string @@ -31,6 +32,19 @@ OrganizationOwner = load_model("openwisp_users", "OrganizationOwner") +def _full_clean_or_raise(instance): + """ + Django's ValidationError raised by full_clean() is not caught by DRF + unless it happens inside a serializer's validate(); these call sites + call full_clean() from create()/update(), so it must be converted + manually or it propagates as an unhandled 500. + """ + try: + instance.full_clean() + except DjangoValidationError as e: + raise serializers.ValidationError(serializers.as_serializer_error(e)) + + class OrganizationSerializer(ValidatedModelSerializer): class Meta: model = Organization @@ -77,7 +91,7 @@ def get_queryset(self): queryset = OrganizationUser.objects.filter( Q(organization__in=user.organizations_managed) ) - return queryset.select_related() + return queryset.filter(organization__is_active=True).select_related() class OrganizationOwnerSerializer(serializers.ModelSerializer): @@ -107,6 +121,26 @@ class Meta: "modified", ) + def validate(self, data): + if ( + self.instance + and not self.instance.is_active + and data.get("is_active") is not True + ): + owner_data = data.get("owner") or {} + is_pure_owner_unassignment = ( + set(data.keys()) <= {"owner"} + and owner_data.get("organization_user") is None + ) + if not is_pure_owner_unassignment: + raise serializers.ValidationError( + _( + "This organization is disabled: only re-enabling it, " + "unassigning its owner, or deleting it is allowed." + ) + ) + return super().validate(data) + def update(self, instance, validated_data): if validated_data.get("owner"): org_owner = validated_data.pop("owner") @@ -121,7 +155,7 @@ def update(self, instance, validated_data): org_owner = OrganizationOwner.objects.create( organization=instance, organization_user=org_user ) - org_owner.full_clean() + _full_clean_or_raise(org_owner) org_owner.save() return super().update(instance, validated_data) @@ -138,7 +172,7 @@ def update(self, instance, validated_data): org_owner = OrganizationOwner.objects.create( organization=instance, organization_user=org_user ) - org_owner.full_clean() + _full_clean_or_raise(org_owner) org_owner.save() instance = self.instance or self.Meta.model(**validated_data) @@ -188,9 +222,9 @@ class OrgUserCustomPrimarykeyRelatedField(serializers.PrimaryKeyRelatedField): def get_queryset(self): user = self.context["request"].user if user.is_superuser: - queryset = Organization.objects.all() + queryset = Organization.active.all() else: - queryset = Organization.objects.filter(pk__in=user.organizations_managed) + queryset = Organization.active.filter(pk__in=user.organizations_managed) return queryset @@ -293,7 +327,7 @@ def create(self, validated_data): if org_user_data.get("organization") is not None: org_user_data["user"] = instance org_user_instance = OrganizationUser(**org_user_data) - org_user_instance.full_clean() + _full_clean_or_raise(org_user_instance) org_user_instance.save() if instance.email: @@ -365,20 +399,16 @@ def update(self, instance, validated_data): except OrganizationUser.DoesNotExist: pass if org_user: - if ( - str(org_user_data["organization"].id) - in instance.organizations_dict.keys() - ): - if org_user.is_admin != org_user_data.get("is_admin"): - org_user.is_admin = org_user_data["is_admin"] - org_user.full_clean() - org_user.save() - else: - org_user.delete() + if org_user.is_admin != org_user_data.get("is_admin"): + org_user.is_admin = org_user_data["is_admin"] + _full_clean_or_raise(org_user) + org_user.save() + else: + org_user.delete() else: org_user_data["user"] = instance org_user_instance = OrganizationUser(**org_user_data) - org_user_instance.full_clean() + _full_clean_or_raise(org_user_instance) org_user_instance.save() return super().update(instance, validated_data) diff --git a/openwisp_users/apps.py b/openwisp_users/apps.py index 3c70ba060..d190538ee 100644 --- a/openwisp_users/apps.py +++ b/openwisp_users/apps.py @@ -232,7 +232,7 @@ def update_organizations_dict(cls, instance, signal, **kwargs): @classmethod def create_organization_owner(cls, instance, created, **kwargs): - if not created or not instance.is_admin: + if not created or not instance.is_admin or not instance.organization.is_active: return OrganizationOwner = load_model("openwisp_users", "OrganizationOwner") org_owner_exist = OrganizationOwner.objects.filter( diff --git a/openwisp_users/base/models.py b/openwisp_users/base/models.py index f8471cc10..431dd8d63 100644 --- a/openwisp_users/base/models.py +++ b/openwisp_users/base/models.py @@ -486,14 +486,14 @@ def add_user(self, user, is_admin=False, **kwargs): automatically via a signal receiver. Without this change, the add_user method would throw IntegrityError. """ - if not self.users.all().exists(): is_admin = True OrganizationUser = load_model("openwisp_users", "OrganizationUser") - return OrganizationUser.objects.create( - user=user, organization=self, is_admin=is_admin - ) + org_user = OrganizationUser(user=user, organization=self, is_admin=is_admin) + org_user.full_clean() + org_user.save() + return org_user class BaseOrganizationUser(models.Model): @@ -508,6 +508,14 @@ class Meta: abstract = True def clean(self): + if self.organization_id and not self.organization.is_active: + if self._state.adding: + raise ValidationError( + {"organization": _("Cannot add users to a disabled organization.")} + ) + raise ValidationError( + _("Memberships of a disabled organization cannot be modified.") + ) if ( not self._state.adding and self.user.is_owner(self.organization_id) @@ -538,6 +546,10 @@ class BaseOrganizationOwner(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) def clean(self): + if self.organization_id and not self.organization.is_active: + raise ValidationError( + _("Cannot assign an owner to a disabled organization.") + ) if self.organization_user.organization.pk != self.organization.pk: raise ValidationError( { diff --git a/openwisp_users/multitenancy.py b/openwisp_users/multitenancy.py index b6557fb23..df5f67c4f 100644 --- a/openwisp_users/multitenancy.py +++ b/openwisp_users/multitenancy.py @@ -20,6 +20,9 @@ class MultitenantAdminMixin(object): multitenant_shared_relations = None multitenant_parent = None + # opt-out hook: set to False on subclasses that should allow writes + # to objects belonging to a disabled organization + disabled_organization_write_protection = True def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -58,6 +61,19 @@ def get_queryset(self, request): qsarg = "{0}__organization__in".format(self.multitenant_parent) return qs.filter(**{qsarg: user.organizations_managed}) + def has_change_permission(self, request, obj=None): + """ + Objects belonging to a disabled organization stay readable and + deletable, but cannot be changed, regardless of the user being a + superuser. Subclasses can opt out with + ``disabled_organization_write_protection = False``. + """ + if self.disabled_organization_write_protection and obj is not None: + organization = getattr(obj, "organization", None) + if organization is not None and not organization.is_active: + return False + return super().has_change_permission(request, obj) + def _edit_form(self, request, form): """ Modifies the form querysets as follows; @@ -67,10 +83,14 @@ def _edit_form(self, request, form): or shared relations * do not allow organization field to be empty (shared org) else show everything + Organization choices always exclude disabled organizations, + superusers included. """ fields = form.base_fields user = request.user org_field = fields.get("organization") + if org_field: + org_field.queryset = org_field.queryset.filter(is_active=True) if user.is_superuser and org_field and not org_field.required: org_field.empty_label = SHARED_SYSTEMWIDE_LABEL elif not user.is_superuser: diff --git a/openwisp_users/tests/test_admin.py b/openwisp_users/tests/test_admin.py index 1a2eb331c..952c0c387 100644 --- a/openwisp_users/tests/test_admin.py +++ b/openwisp_users/tests/test_admin.py @@ -27,6 +27,7 @@ from ..apps import logger as apps_logger from ..auth import SESSION_KEY from ..multitenancy import MultitenantAdminMixin +from ..widgets import OrganizationAutocompleteSelect from .utils import ( TestMultitenantAdminMixin, TestOrganizationMixin, @@ -1884,6 +1885,176 @@ def test_only_superuser_can_delete_inline_org_owner(self): self.assertEqual(r.status_code, 200) self.assertContains(r, '-DELETE">Delete') + def test_disabled_organization_change_form(self): + admin = self._get_admin() + self.client.force_login(admin) + org = self._create_org(name="disabled-admin-org", is_active=False) + path = reverse(f"admin:{self.app_label}_organization_change", args=[org.pk]) + + with self.subTest("Fields readonly except is_active, no 500"): + response = self.client.get(path) + self.assertEqual(response.status_code, 200) + self.assertNotContains( + response, f'/", + views.template_disabled_org_write_allowed_detail, + name="test_template_disabled_org_write_allowed_detail", + ), + path( + "protected_template//", + views.protected_template_detail, + name="test_protected_template_detail", + ), path( "library/", views.library_list, diff --git a/tests/testapp/views.py b/tests/testapp/views.py index 77d2a7994..795eb899f 100644 --- a/tests/testapp/views.py +++ b/tests/testapp/views.py @@ -22,9 +22,11 @@ FilterByParentMembership, FilterByParentOwned, FilterDjangoByOrgManaged, + ProtectedAPIMixin, ) from openwisp_users.api.permissions import ( BaseOrganizationPermission, + DisabledOrgReadOnly, DjangoModelPermissions, IsOrganizationManager, IsOrganizationMember, @@ -211,6 +213,7 @@ class TemplateListCreateView(FilterByOrganizationManaged, ListCreateAPIView): permission_classes = ( IsOrganizationMember, DjangoModelPermissions, + DisabledOrgReadOnly, ) queryset = Template.objects.all() @@ -221,10 +224,28 @@ class TemplateDetailView(FilterByOrganizationManaged, RetrieveUpdateDestroyAPIVi permission_classes = ( IsOrganizationMember, DjangoModelPermissions, + DisabledOrgReadOnly, ) queryset = Template.objects.all() +class TemplateDisabledOrgWriteAllowedDetailView(TemplateDetailView): + allow_disabled_organization_writes = True + + +class ProtectedTemplateDetailView( + ProtectedAPIMixin, FilterByOrganizationManaged, RetrieveUpdateDestroyAPIView +): + """ + Uses ProtectedAPIMixin directly, with no permission_classes/ + authentication_classes override, to prove the disabled-organization + guard is inherited automatically rather than manually re-declared. + """ + + serializer_class = TemplateSerializer + queryset = Template.objects.all() + + class LibraryListFilter(FilterDjangoByOrgManaged): class Meta: model = Library @@ -285,6 +306,10 @@ class ShelfWithReadOnlyOrgListCreateView( shelf_list_owner_view = ShelfListOwnerView.as_view() template_list = TemplateListCreateView.as_view() template_detail = TemplateDetailView.as_view() +template_disabled_org_write_allowed_detail = ( + TemplateDisabledOrgWriteAllowedDetailView.as_view() +) +protected_template_detail = ProtectedTemplateDetailView.as_view() library_list = LibraryListCreateView.as_view() library_detail = LibraryDetailView.as_view() book_nested_shelf = BookNestedShelfListCreateView.as_view() From e93cdb4d520729a157ee0c6fca6081a9cc05767c Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Tue, 14 Jul 2026 22:16:37 +0530 Subject: [PATCH 02/34] [fix] Fixed review comments --- docs/developer/admin-utils.rst | 6 +- .../developer/django-rest-framework-utils.rst | 13 ++ docs/user/basic-concepts.rst | 18 ++- openwisp_users/admin.py | 54 +++++++- openwisp_users/api/serializers.py | 67 ++++++---- openwisp_users/base/models.py | 32 ++++- openwisp_users/multitenancy.py | 49 ++++++- openwisp_users/tests/test_admin.py | 123 ++++++++++++++++++ openwisp_users/tests/test_api/test_api.py | 63 ++++++++- openwisp_users/tests/test_models.py | 25 ++++ openwisp_users/views.py | 5 +- tests/testapp/tests/test_admin.py | 3 +- tests/testapp/tests/test_multitenancy.py | 64 ++++++++- tests/testapp/tests/test_views.py | 8 ++ 14 files changed, 489 insertions(+), 41 deletions(-) diff --git a/docs/developer/admin-utils.rst b/docs/developer/admin-utils.rst index 30ae19e03..8afd015c4 100644 --- a/docs/developer/admin-utils.rst +++ b/docs/developer/admin-utils.rst @@ -37,7 +37,11 @@ Disabled Organization Write Protection ``MultitenantAdminMixin`` also blocks changes to any object belonging to a :ref:`disabled organization `, while still allowing that object to be viewed and deleted. This applies to superusers -too: there is no bypass. +too: there is no per-user bypass (the only way to opt out is the +class-level attribute described below). For models whose organization is +reached through a parent (via ``multitenant_parent``), the mixin traverses +the parent to find the organization, so those child objects are protected +as well. This is controlled by the ``disabled_organization_write_protection`` class attribute, which defaults to ``True``. Set it to ``False`` on a specific diff --git a/docs/developer/django-rest-framework-utils.rst b/docs/developer/django-rest-framework-utils.rst index f1693ecd4..122ed6dab 100644 --- a/docs/developer/django-rest-framework-utils.rst +++ b/docs/developer/django-rest-framework-utils.rst @@ -141,6 +141,19 @@ This object-level permission class blocks updating an object that belongs to a :ref:`disabled organization `. Read (safe methods) and ``DELETE`` remain allowed. +.. important:: + + ``DisabledOrgReadOnly`` guards **updates only**. It implements + ``has_object_permission``, which DRF does not call on ``POST``, so it + does **not** block *creating* a new object for a disabled + organization. Create protection instead relies on the organization + field excluding disabled organizations: use one of the + ``FilterSerializerByOrganization`` mixins (or a related field backed + by ``Organization.active``) on the serializer. A plain + ``ModelSerializer`` whose organization field defaults to + ``Organization.objects`` will happily create objects for a disabled + organization even under ``ProtectedAPIMixin``. + A view can opt out of this guard by setting ``allow_disabled_organization_writes = True``: diff --git a/docs/user/basic-concepts.rst b/docs/user/basic-concepts.rst index d7c4001a4..12360acc4 100644 --- a/docs/user/basic-concepts.rst +++ b/docs/user/basic-concepts.rst @@ -175,8 +175,12 @@ including users, memberships, and related objects, remains fully (e.g. when creating a new object), so it can no longer be picked for new data. It still appears in admin **list filters**, so its existing data remains easy to find for auditing purposes. -- Re-enabling a disabled organization is allowed for the same users who - can disable it: superusers and managers of that organization. +- Re-enabling a disabled organization is allowed **only for superusers**. + Once an organization is disabled, its managers lose access to it (a + disabled organization is no longer part of the organizations they + manage), so they can no longer edit it, including re-enabling it. A + superuser must re-enable the organization before its managers regain + access. .. note:: @@ -185,6 +189,16 @@ including users, memberships, and related objects, remains fully error message, instead of failing silently or being blocked without explanation. +.. note:: + + Re-enabling an organization and editing its other fields must be done + in **two separate steps**, matching the admin interface (which locks + every field except **Is active** while the organization is disabled). + First re-enable the organization (change only **Is active**), then + edit its other fields or assign an owner. A single request that both + re-enables the organization and changes another field (or assigns an + owner) is rejected. + Organization Membership and Roles --------------------------------- diff --git a/openwisp_users/admin.py b/openwisp_users/admin.py index 05081b9cf..6dbba805b 100644 --- a/openwisp_users/admin.py +++ b/openwisp_users/admin.py @@ -38,6 +38,7 @@ from . import settings as app_settings from .multitenancy import MultitenantAdminMixin, MultitenantOrgFilter from .utils import BaseAdmin +from .widgets import OrganizationAutocompleteSelect Group = load_model("openwisp_users", "Group") Organization = load_model("openwisp_users", "Organization") @@ -112,9 +113,36 @@ def has_change_permission(self, request, obj=None): return super().has_change_permission(request, obj) +class OrganizationUserInlineFormSet(RequiredInlineFormSet): + """ + Renders existing memberships of a disabled organization as read-only so + the row survives a no-op save (the disabled organization is not part of + the field queryset otherwise) and its select widget shows the disabled + organization instead of rendering empty. Deleting the row stays possible. + """ + + def add_fields(self, form, index): + super().add_fields(form, index) + instance = getattr(form, "instance", None) + if ( + instance + and instance.pk + and instance.organization_id + and not instance.organization.is_active + ): + org_field = form.fields.get("organization") + if org_field is not None: + org_field.disabled = True + org_field.queryset = Organization.objects.filter( + pk=instance.organization_id + ) + if "is_admin" in form.fields: + form.fields["is_admin"].disabled = True + + class OrganizationUserInline(admin.StackedInline): model = OrganizationUser - formset = RequiredInlineFormSet + formset = OrganizationUserInlineFormSet view_on_site = False fields = ("organization", "is_admin") autocomplete_fields = ("organization",) @@ -135,6 +163,30 @@ def get_formset(self, request, obj=None, **kwargs): ) return formset + def formfield_for_foreignkey(self, db_field, request, **kwargs): + """ + Route the organization picker through the ``ow-auto-filter`` endpoint + so disabled organizations are excluded from the dropdown for everyone, + superusers included (the stock ``admin:autocomplete`` endpoint does not + filter them). Only replaces the widget when the field is actually an + autocomplete field, so that disabling ``autocomplete_fields`` keeps + rendering a plain select. + """ + if db_field.name == "organization" and db_field.name in ( + self.get_autocomplete_fields(request) + ): + kwargs["widget"] = OrganizationAutocompleteSelect( + db_field, self.admin_site, using=kwargs.get("using") + ) + return super().formfield_for_foreignkey(db_field, request, **kwargs) + + def has_add_permission(self, request, obj=None): + # an operator who manages no active organization cannot pick one, so + # the add row would be unusable: hide it + if not request.user.is_superuser and not request.user.organizations_managed: + return False + return super().has_add_permission(request, obj) + def get_extra(self, request, obj=None, **kwargs): if not obj: return 1 diff --git a/openwisp_users/api/serializers.py b/openwisp_users/api/serializers.py index dfa2e97ca..d9bd53866 100644 --- a/openwisp_users/api/serializers.py +++ b/openwisp_users/api/serializers.py @@ -122,21 +122,30 @@ class Meta: ) def validate(self, data): - if ( - self.instance - and not self.instance.is_active - and data.get("is_active") is not True - ): + if self.instance and not self.instance.is_active: + keys = set(data.keys()) owner_data = data.get("owner") or {} - is_pure_owner_unassignment = ( - set(data.keys()) <= {"owner"} - and owner_data.get("organization_user") is None + owner_present = "owner" in data + is_owner_unassignment = ( + owner_present and owner_data.get("organization_user") is None + ) + reenabling = data.get("is_active") is True + # While disabled, only re-enabling (Is active) and/or unassigning + # the owner are allowed, and neither can be combined with any other + # change (editing a field or assigning an owner). This matches the + # admin interface, which locks every field except Is active, so the + # admin, the API and the docs tell the same story. + allowed = ( + keys <= {"is_active", "owner"} + and (not owner_present or is_owner_unassignment) + and (reenabling or is_owner_unassignment) ) - if not is_pure_owner_unassignment: + if not allowed: raise serializers.ValidationError( _( "This organization is disabled: only re-enabling it, " - "unassigning its owner, or deleting it is allowed." + "unassigning its owner, or deleting it is allowed. Edit " + "other fields or assign an owner after re-enabling it." ) ) return super().validate(data) @@ -219,6 +228,12 @@ def update(self, instance, validated_data): class OrgUserCustomPrimarykeyRelatedField(serializers.PrimaryKeyRelatedField): + default_error_messages = { + "does_not_exist": _( + 'Organization with pk "{pk_value}" does not exist or is disabled.' + ), + } + def get_queryset(self): user = self.context["request"].user if user.is_superuser: @@ -315,20 +330,24 @@ def create(self, validated_data): password = validated_data.pop("password") email_verified = validated_data.pop("email_verified", False) - instance = self.instance or self.Meta.model(**validated_data) - instance.set_password(password) - instance.full_clean() - instance.save() - - if group_data: - instance.groups.add(*group_data) - - if org_user_data: - if org_user_data.get("organization") is not None: - org_user_data["user"] = instance - org_user_instance = OrganizationUser(**org_user_data) - _full_clean_or_raise(org_user_instance) - org_user_instance.save() + # Keep user and membership creation in a single transaction so a + # membership validation failure does not leave a half-created user + # behind while _full_clean_or_raise returns a 400. + with transaction.atomic(): + instance = self.instance or self.Meta.model(**validated_data) + instance.set_password(password) + instance.full_clean() + instance.save() + + if group_data: + instance.groups.add(*group_data) + + if org_user_data: + if org_user_data.get("organization") is not None: + org_user_data["user"] = instance + org_user_instance = OrganizationUser(**org_user_data) + _full_clean_or_raise(org_user_instance) + org_user_instance.save() if instance.email: try: diff --git a/openwisp_users/base/models.py b/openwisp_users/base/models.py index 431dd8d63..18c0b1a90 100644 --- a/openwisp_users/base/models.py +++ b/openwisp_users/base/models.py @@ -513,9 +513,21 @@ def clean(self): raise ValidationError( {"organization": _("Cannot add users to a disabled organization.")} ) - raise ValidationError( - _("Memberships of a disabled organization cannot be modified.") + # Only block real modifications: Django re-runs full_clean() on + # untouched inline rows, so a no-op save of a user who belongs to a + # disabled organization must not fail. + db_values = ( + self._meta.model.objects.filter(pk=self.pk) + .values("organization_id", "is_admin") + .first() ) + if db_values is None or ( + db_values["organization_id"] != self.organization_id + or db_values["is_admin"] != self.is_admin + ): + raise ValidationError( + _("Memberships of a disabled organization cannot be modified.") + ) if ( not self._state.adding and self.user.is_owner(self.organization_id) @@ -547,9 +559,21 @@ class BaseOrganizationOwner(models.Model): def clean(self): if self.organization_id and not self.organization.is_active: - raise ValidationError( - _("Cannot assign an owner to a disabled organization.") + # Only block assigning or changing an owner: an untouched owner row + # is re-validated when its organization is disabled, and that must + # not prevent disabling the organization. + db_values = ( + self._meta.model.objects.filter(pk=self.pk) + .values("organization_id", "organization_user_id") + .first() ) + if db_values is None or ( + db_values["organization_id"] != self.organization_id + or db_values["organization_user_id"] != self.organization_user_id + ): + raise ValidationError( + _("Cannot assign an owner to a disabled organization.") + ) if self.organization_user.organization.pk != self.organization.pk: raise ValidationError( { diff --git a/openwisp_users/multitenancy.py b/openwisp_users/multitenancy.py index df5f67c4f..99d34680b 100644 --- a/openwisp_users/multitenancy.py +++ b/openwisp_users/multitenancy.py @@ -61,6 +61,22 @@ def get_queryset(self, request): qsarg = "{0}__organization__in".format(self.multitenant_parent) return qs.filter(**{qsarg: user.organizations_managed}) + def _get_object_organization(self, obj): + """ + Returns the organization an object belongs to, traversing + ``multitenant_parent`` for models whose organization is reached + through a parent (e.g. a Book through its Shelf). + """ + organization = getattr(obj, "organization", None) + if organization is None and self.multitenant_parent: + parent = obj + for attr in self.multitenant_parent.split("__"): + parent = getattr(parent, attr, None) + if parent is None: + break + organization = getattr(parent, "organization", None) + return organization + def has_change_permission(self, request, obj=None): """ Objects belonging to a disabled organization stay readable and @@ -69,11 +85,42 @@ def has_change_permission(self, request, obj=None): ``disabled_organization_write_protection = False``. """ if self.disabled_organization_write_protection and obj is not None: - organization = getattr(obj, "organization", None) + organization = self._get_object_organization(obj) if organization is not None and not organization.is_active: return False return super().has_change_permission(request, obj) + def has_add_permission(self, request, *args, **kwargs): + """ + Hide the Add button from operators who manage no active organization: + the organization dropdown would be empty and the form could never be + submitted. Does not apply to the user admin or to models without an + organization (directly or through ``multitenant_parent``). + + ``*args`` keeps this compatible with both ``ModelAdmin`` + (``request``) and ``InlineModelAdmin`` (``request, obj``), since this + mixin is used on inlines too. + """ + if ( + not request.user.is_superuser + and self.model != User + and not request.user.organizations_managed + ): + org_field = ( + self.model._meta.get_field("organization") + if hasattr(self.model, "organization") + else None + ) + # If the model has a required organization field (OrgMixin, not + # ShareableOrgMixin which allows null/blank), the dropdown would be + # empty — block. If it's optional or reached through a parent, + # the form can still be submitted without picking an org. + if org_field is not None: + return False + if org_field is None and self.multitenant_parent: + return False + return super().has_add_permission(request, *args, **kwargs) + def _edit_form(self, request, form): """ Modifies the form querysets as follows; diff --git a/openwisp_users/tests/test_admin.py b/openwisp_users/tests/test_admin.py index 952c0c387..f3cf035aa 100644 --- a/openwisp_users/tests/test_admin.py +++ b/openwisp_users/tests/test_admin.py @@ -1914,6 +1914,39 @@ def test_disabled_organization_change_form(self): org.refresh_from_db() self.assertEqual(org.is_active, True) + def test_disable_organization_with_owner(self): + admin = self._get_admin() + self.client.force_login(admin) + org = self._create_org(name="org-with-owner") + user = self._create_user( + username="ownerdisable", email="ownerdisable@example.com" + ) + org_user = self._create_org_user(organization=org, user=user, is_admin=True) + org_owner = OrganizationOwner.objects.get(organization_user=org_user) + path = reverse(f"admin:{self.app_label}_organization_change", args=[org.pk]) + params = { + "name": org.name, + "slug": org.slug, + # unchecking Is active must be allowed even when an owner exists + "is_active": "", + "owner-TOTAL_FORMS": "1", + "owner-INITIAL_FORMS": "1", + "owner-MIN_NUM_FORMS": "0", + "owner-MAX_NUM_FORMS": "1", + "owner-0-organization_user": f"{org_user.pk}", + "owner-0-organization": f"{org.pk}", + "owner-0-id": f"{org_owner.pk}", + } + params.update(self._get_org_edit_form_inline_params(admin, org)) + response = self.client.post(path, params, follow=True) + self.assertNotContains( + response, "Cannot assign an owner to a disabled organization" + ) + self.assertNotContains(response, "Please correct the error") + org.refresh_from_db() + self.assertEqual(org.is_active, False) + self.assertEqual(OrganizationOwner.objects.filter(pk=org_owner.pk).count(), 1) + def test_organization_owner_inline_disabled_organization(self): admin = self._get_admin() self.client.force_login(admin) @@ -2055,6 +2088,96 @@ def test_user_admin_inline_disabled_organization(self): self.assertContains(res, "errors field-organization") self.assertEqual(User.objects.filter(username="disableduserinline").count(), 0) + def test_user_inline_org_picker_excludes_disabled(self): + # the membership organization picker must go through the ow-auto-filter + # endpoint with exclude_disabled=true, so disabled orgs are not offered + admin = self._get_admin() + self.client.force_login(admin) + response = self.client.get(reverse(f"admin:{self.app_label}_user_add")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "exclude_disabled=true") + + def test_user_admin_change_with_disabled_org_membership(self): + admin = self._get_admin() + self.client.force_login(admin) + org = self._create_org(name="disabled-membership-org") + user = self._create_user( + username="memberofdisabled", email="memberofdisabled@example.com" + ) + org_user = self._create_org_user(organization=org, user=user, is_admin=True) + org.is_active = False + org.save() + path = reverse(f"admin:{self.app_label}_user_change", args=[user.pk]) + inline_prefix = f"{self.app_label}_organizationuser" + + def _base_params(): + params = user.__dict__.copy() + params["groups"] = [] + params.pop("phone_number", None) + params.pop("password", None) + params.pop("_password", None) + params.pop("last_login") + params.pop("password_updated") + params.pop("expiration_date", None) + params = self._additional_params_pop(params) + params.update(self.add_user_inline_params) + params.update(self._get_user_edit_form_inline_params(user, org)) + return params + + with self.subTest("disabled-org membership is rendered read-only"): + response = self.client.get(path) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "disabled-membership-org") + self.assertContains( + response, f'name="{inline_prefix}-0-organization" disabled' + ) + + with self.subTest("editing an unrelated field saves without error"): + params = _base_params() + params["first_name"] = "Changed" + # the disabled inline fields are not submitted by a real browser + params.update( + { + f"{inline_prefix}-TOTAL_FORMS": 1, + f"{inline_prefix}-INITIAL_FORMS": 1, + f"{inline_prefix}-MIN_NUM_FORMS": 0, + f"{inline_prefix}-MAX_NUM_FORMS": 1000, + f"{inline_prefix}-0-id": str(org_user.pk), + } + ) + response = self.client.post(path, params, follow=True) + self.assertNotContains(response, "Please correct the error") + self.assertNotContains(response, "Select a valid choice") + user.refresh_from_db() + self.assertEqual(user.first_name, "Changed") + # the membership must still exist and be unchanged + org_user.refresh_from_db() + self.assertEqual(org_user.organization_id, org.pk) + self.assertEqual(org_user.is_admin, True) + + with self.subTest("a new active-org membership can be added alongside it"): + active_org = self._create_org(name="active-alongside-org") + params = _base_params() + params.update( + { + f"{inline_prefix}-TOTAL_FORMS": 2, + f"{inline_prefix}-INITIAL_FORMS": 1, + f"{inline_prefix}-MIN_NUM_FORMS": 0, + f"{inline_prefix}-MAX_NUM_FORMS": 1000, + f"{inline_prefix}-0-id": str(org_user.pk), + f"{inline_prefix}-1-organization": str(active_org.pk), + f"{inline_prefix}-1-is_admin": "on", + } + ) + response = self.client.post(path, params, follow=True) + self.assertNotContains(response, "Please correct the error") + self.assertEqual( + OrganizationUser.objects.filter( + user=user, organization=active_org + ).count(), + 1, + ) + def test_delete_org_user(self): self.client.force_login(self._get_admin()) user1 = self._create_user(username="user1", email="user1@email.com") diff --git a/openwisp_users/tests/test_api/test_api.py b/openwisp_users/tests/test_api/test_api.py index 23258afa3..7747740a0 100644 --- a/openwisp_users/tests/test_api/test_api.py +++ b/openwisp_users/tests/test_api/test_api.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest import mock import django from allauth.account.models import EmailAddress @@ -6,6 +6,7 @@ from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission from django.core import mail +from django.core.exceptions import ValidationError as DjangoValidationError from django.test import TestCase from django.urls import reverse from django.utils.timezone import localdate, timedelta @@ -63,7 +64,7 @@ def test_organization_list_nonsuperuser_api(self): def test_organization_post_api(self): path = reverse("users:organization_list") data = {"name": "test-org", "slug": "test-org"} - with self.assertNumQueries(7): + with self.assertNumQueries(6): r = self.client.post(path, data, content_type="application/json") self.assertEqual(r.status_code, 201) self.assertEqual(Organization.objects.count(), 2) @@ -139,6 +140,20 @@ def test_patch_disabled_organization_reenable_api(self): org1.refresh_from_db() self.assertTrue(org1.is_active) + def test_reenable_disabled_organization_with_field_edit_api(self): + org1 = self._get_org() + org1.is_active = False + org1.save() + path = reverse("users:organization_detail", args=(org1.pk,)) + # re-enabling and editing another field in one request is rejected, + # the two-step matches the admin and the docs + data = {"is_active": True, "name": "renamed while disabled"} + r = self.client.patch(path, data, content_type="application/json") + self.assertEqual(r.status_code, 400) + org1.refresh_from_db() + self.assertEqual(org1.is_active, False) + self.assertEqual(org1.name, "test org") + def test_create_organization_owner_api(self): user1 = self._create_user(username="user1", email="user1@email.com") org1 = self._create_org(name="org1") @@ -508,7 +523,7 @@ def _login_expired_admin(self): self.client.force_login(admin) return admin - @patch.object(app_settings, "STAFF_USER_PASSWORD_EXPIRATION", 10) + @mock.patch.object(app_settings, "STAFF_USER_PASSWORD_EXPIRATION", 10) def test_expired_password_session_blocks_change_password_of_other_user(self): self._login_expired_admin() other_user = self._create_user(username="other", password="tester") @@ -681,6 +696,27 @@ def test_create_user_organization_users_disabled_org_api(self): self.assertEqual(User.objects.filter(username="tester").count(), 0) self.assertEqual(OrganizationUser.objects.filter(organization=org1).count(), 0) + def test_create_user_membership_failure_rolls_back_user_api(self): + # A membership validation failure after the user row is written must + # roll the user back instead of leaving a half-created account behind. + path = reverse("users:user_list") + org1 = self._get_org() + data = { + "username": "rollbackuser", + "email": "rollbackuser@test.com", + "password": "password", + "organization_users": {"is_admin": False, "organization": org1.pk}, + } + with mock.patch.object( + OrganizationUser, + "full_clean", + side_effect=DjangoValidationError("membership boom"), + ): + r = self.client.post(path, data, content_type="application/json") + self.assertEqual(r.status_code, 400) + self.assertEqual(User.objects.filter(username="rollbackuser").count(), 0) + self.assertEqual(OrganizationUser.objects.filter(organization=org1).count(), 0) + def test_post_with_no_email(self): path = reverse("users:user_list") data = {"username": "", "email": "", "password": ""} @@ -787,6 +823,25 @@ def test_toggle_org_admin_disabled_org_api(self): OrganizationUser.objects.get(user=user1, organization=org1).is_admin ) + def test_patch_resend_disabled_org_membership_preserves_it_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=False) + org1.is_active = False + org1.save() + path = reverse("users:user_detail", args=(user1.pk,)) + # Re-sending an unchanged membership of a disabled organization must + # not silently delete it. The membership field only accepts active + # organizations, so the request is rejected (400) before the toggle + # delete path can run, and the membership is preserved. + data = {"organization_users": [{"is_admin": False, "organization": org1.pk}]} + r = self.client.patch(path, data, content_type="application/json") + self.assertEqual(r.status_code, 400) + self.assertIn("does not exist or is disabled", str(r.data)) + self.assertEqual( + OrganizationUser.objects.filter(user=user1, organization=org1).count(), 1 + ) + def test_assign_user_to_groups_api(self): user = self._get_user() self.assertEqual(user.groups.count(), 0) @@ -862,7 +917,7 @@ def test_user_list_for_nonsuperuser_api(self): def test_organization_slug_post_custom_validation_api(self): path = reverse("users:organization_list") data = {"name": "test-org", "slug": "test-org"} - with self.assertNumQueries(7): + with self.assertNumQueries(6): r = self.client.post(path, data, content_type="application/json") self.assertEqual(r.status_code, 201) self.assertEqual(Organization.objects.count(), 2) diff --git a/openwisp_users/tests/test_models.py b/openwisp_users/tests/test_models.py index a901ed94e..4455eaaf5 100644 --- a/openwisp_users/tests/test_models.py +++ b/openwisp_users/tests/test_models.py @@ -457,6 +457,17 @@ def test_organization_user_clean_disabled_organization(self): ): org_user.full_clean() + with self.subTest("unchanged membership of a disabled organization passes"): + org = self._create_org(name="test-org-noop") + user = self._create_user(username="user5", email="user5@example.com") + org_user = self._create_org_user(organization=org, user=user) + org.is_active = False + org.save() + org_user.refresh_from_db() + # a no-op full_clean() (nothing changed) must not raise, otherwise + # Django admin cannot save a user who has a disabled-org membership + org_user.full_clean() + def test_organization_owner_clean_disabled_organization(self): with self.subTest("assign an owner to a disabled organization"): org = self._create_org(name="disabled-org-owner") @@ -484,6 +495,20 @@ def test_organization_owner_clean_disabled_organization(self): OrganizationOwner.objects.filter(pk=org_owner.pk).count(), 0 ) + with self.subTest("unchanged owner of a disabled organization passes"): + org = self._create_org(name="test-org-owner-noop") + user = self._create_user(username="user6", email="user6@example.com") + org_user = self._create_org_user(organization=org, user=user) + org_owner = self._create_org_owner( + organization=org, organization_user=org_user + ) + org.is_active = False + org.save() + org_owner.refresh_from_db() + # disabling an organization that already has an owner must not fail + # when the untouched owner row is re-validated + org_owner.full_clean() + def test_create_organization_owner_signal_defends_bypassed_validation(self): # Django never runs full_clean() automatically on save(), so this # models a write that bypasses validation (migration, fixture, diff --git a/openwisp_users/views.py b/openwisp_users/views.py index b74f76fde..95f3d9e29 100644 --- a/openwisp_users/views.py +++ b/openwisp_users/views.py @@ -34,7 +34,10 @@ def get_queryset(self): org_lookup = self.get_org_lookup() if not self.request.user.is_superuser and org_lookup: qs = qs.filter(**{org_lookup: self.request.user.organizations_managed}) - if qs.model == Organization and self.request.GET.get("exclude_disabled"): + if ( + qs.model == Organization + and self.request.GET.get("exclude_disabled") == "true" + ): qs = qs.filter(is_active=True) return qs diff --git a/tests/testapp/tests/test_admin.py b/tests/testapp/tests/test_admin.py index b27b3a337..e1477368e 100644 --- a/tests/testapp/tests/test_admin.py +++ b/tests/testapp/tests/test_admin.py @@ -47,7 +47,8 @@ def test_accounts_login(self): class TestTemplateAdmin(TestOrganizationMixin, TestCase): def test_org_admin_create_shareable_template(self): - administrator = self._create_administrator() + org = self._create_org(name="test-org") + administrator = self._create_administrator(organizations=[org]) self.client.force_login(administrator) response = self.client.post( reverse("admin:testapp_template_add"), diff --git a/tests/testapp/tests/test_multitenancy.py b/tests/testapp/tests/test_multitenancy.py index f9fbfed8d..f11050d8c 100644 --- a/tests/testapp/tests/test_multitenancy.py +++ b/tests/testapp/tests/test_multitenancy.py @@ -1,9 +1,22 @@ -from django.test import TestCase +from django.contrib import admin +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission +from django.test import RequestFactory, TestCase from django.urls import reverse -from ..models import Book, Shelf +from openwisp_users.multitenancy import MultitenantAdminMixin + +from ..admin import ShelfAdmin +from ..models import Book, Library, Shelf from .mixins import TestMultitenancyMixin +User = get_user_model() + + +class LibraryParentAdmin(MultitenantAdminMixin, admin.ModelAdmin): + # Library has no organization field; it is reached through its Book parent + multitenant_parent = "book" + class TestMultitenancy(TestMultitenancyMixin, TestCase): book_model = Book @@ -95,3 +108,50 @@ def test_shelf_disabled_organization_admin_guard(self): r = self.client.post(delete_path, {"post": "yes"}, follow=True) self.assertEqual(r.status_code, 200) self.assertEqual(self.shelf_model.objects.filter(pk=shelf.pk).count(), 0) + + def test_multitenant_parent_disabled_organization_guard(self): + data = self._create_multitenancy_test_env() + library_admin = LibraryParentAdmin(Library, admin.site) + request = RequestFactory().get("/") + request.user = self._get_admin() + active_library = Library.objects.create(name="lib-active", book=data["b1"]) + disabled_library = Library.objects.create( + name="lib-disabled", book=data["b3_inactive"] + ) + + with self.subTest("change allowed for object of active parent org"): + self.assertEqual( + library_admin.has_change_permission(request, active_library), True + ) + + with self.subTest("change blocked for object of disabled parent org"): + # applies to superusers too: the object is reached through + # multitenant_parent, so the guard must traverse it + self.assertEqual( + library_admin.has_change_permission(request, disabled_library), False + ) + + with self.subTest("delete still allowed for object of disabled parent org"): + self.assertEqual( + library_admin.has_delete_permission(request, disabled_library), True + ) + + def test_add_permission_hidden_without_active_managed_org(self): + disabled_org = self._create_org(name="operator-disabled-org", is_active=False) + active_org = self._create_org(name="operator-active-org") + operator = self._create_operator() + operator.user_permissions.add(Permission.objects.get(codename="add_shelf")) + shelf_admin = ShelfAdmin(Shelf, admin.site) + request = RequestFactory().get("/") + + with self.subTest("no active managed org hides the Add button"): + self._create_org_user( + user=operator, organization=disabled_org, is_admin=True + ) + request.user = User.objects.get(pk=operator.pk) + self.assertEqual(shelf_admin.has_add_permission(request), False) + + with self.subTest("an active managed org restores the Add button"): + self._create_org_user(user=operator, organization=active_org, is_admin=True) + request.user = User.objects.get(pk=operator.pk) + self.assertEqual(shelf_admin.has_add_permission(request), True) diff --git a/tests/testapp/tests/test_views.py b/tests/testapp/tests/test_views.py index 60c4833ad..7859ad91b 100644 --- a/tests/testapp/tests/test_views.py +++ b/tests/testapp/tests/test_views.py @@ -83,6 +83,14 @@ def test_autocomplete_view_excludes_disabled_organization(self): self.assertIn(str(org1.pk), ids) self.assertIn(str(org2.pk), ids) + with self.subTest("exclude_disabled=false keeps disabled org"): + # only the literal "true" enables the filter, otherwise a value + # like "false" would wrongly exclude disabled organizations + response = self.client.get(path + "&exclude_disabled=false") + ids = [option["id"] for option in response.json()["results"]] + self.assertIn(str(org1.pk), ids) + self.assertIn(str(org2.pk), ids) + def test_autocomplete_view_for_inline_admin(self): admin = self._get_admin() self.client.force_login(admin) From c39236412aa2394553fdd6f11ece546774540531 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Wed, 15 Jul 2026 19:06:58 +0530 Subject: [PATCH 03/34] [fix] Fixes by @coderabbitai --- openwisp_users/admin.py | 6 ++ openwisp_users/api/mixins.py | 19 +++-- openwisp_users/api/serializers.py | 19 +++-- openwisp_users/base/models.py | 3 +- openwisp_users/multitenancy.py | 43 ++++++----- openwisp_users/tests/test_api/test_api.py | 88 +++++++++++++---------- openwisp_users/tests/test_models.py | 15 ++++ tests/testapp/admin.py | 7 +- tests/testapp/tests/test_multitenancy.py | 38 ++++++++-- 9 files changed, 163 insertions(+), 75 deletions(-) diff --git a/openwisp_users/admin.py b/openwisp_users/admin.py index 6dbba805b..43349467a 100644 --- a/openwisp_users/admin.py +++ b/openwisp_users/admin.py @@ -147,6 +147,12 @@ class OrganizationUserInline(admin.StackedInline): fields = ("organization", "is_admin") autocomplete_fields = ("organization",) + def get_queryset(self, request): + # OrganizationUserInlineFormSet.add_fields() reads + # instance.organization.is_active for every row; select_related + # folds that per-row query into this one. + return super().get_queryset(request).select_related("organization") + def get_formset(self, request, obj=None, **kwargs): """ In form dropdowns, display only active organizations; diff --git a/openwisp_users/api/mixins.py b/openwisp_users/api/mixins.py index ea23dcc91..b98212aa2 100644 --- a/openwisp_users/api/mixins.py +++ b/openwisp_users/api/mixins.py @@ -17,6 +17,10 @@ Organization = swapper.load_model("openwisp_users", "Organization") +DISABLED_ORGANIZATION_ERROR_MESSAGE = _( + 'Organization with pk "{pk_value}" does not exist or is disabled.' +) + class OrgLookup: @property @@ -165,8 +169,9 @@ def _user_attr(self): def filter_fields(self): user = self.context["request"].user # superuser can see everything, except disabled organizations - superuser = user.is_superuser or user.is_anonymous - if not superuser: + # The anonymouse use case exist so we don't run into errors with swagger + is_superuser_or_anonymous = user.is_superuser or user.is_anonymous + if not is_superuser_or_anonymous: # non superusers can see only items of organizations # they're related to organization_filter = getattr(user, self._user_attr) @@ -176,15 +181,15 @@ def filter_fields(self): # disabled organizations are excluded for everyone, superusers # included, since they can only be re-enabled, not written to queryset = self.fields[field].queryset.filter(is_active=True) - self.fields[field].error_messages["does_not_exist"] = _( - 'Organization with pk "{pk_value}" does not exist or is disabled.' - ) - if not superuser: + self.fields[field].error_messages[ + "does_not_exist" + ] = DISABLED_ORGANIZATION_ERROR_MESSAGE + if not is_superuser_or_anonymous: self.fields[field].allow_null = False queryset = queryset.filter(pk__in=organization_filter) self.fields[field].queryset = queryset continue - if superuser: + if is_superuser_or_anonymous: continue conditions = Q(**{self.organization_lookup: organization_filter}) if self.include_shared: diff --git a/openwisp_users/api/serializers.py b/openwisp_users/api/serializers.py index d9bd53866..d06a11a5e 100644 --- a/openwisp_users/api/serializers.py +++ b/openwisp_users/api/serializers.py @@ -23,6 +23,7 @@ from openwisp_utils.api.serializers import ValidatedModelSerializer from .. import settings as app_settings +from .mixins import DISABLED_ORGANIZATION_ERROR_MESSAGE Group = load_model("openwisp_users", "Group") Organization = load_model("openwisp_users", "Organization") @@ -123,20 +124,30 @@ class Meta: def validate(self, data): if self.instance and not self.instance.is_active: - keys = set(data.keys()) owner_data = data.get("owner") or {} owner_present = "owner" in data is_owner_unassignment = ( owner_present and owner_data.get("organization_user") is None ) reenabling = data.get("is_active") is True + # A key whose submitted value matches the value already stored is + # not a change, so a read-modify-write PUT that resends every + # field unchanged except is_active must not be rejected just + # because e.g. "name" is present in the payload. + changed_keys = { + key + for key in data + if key != "owner" and getattr(self.instance, key) != data[key] + } + if owner_present: + changed_keys.add("owner") # While disabled, only re-enabling (Is active) and/or unassigning # the owner are allowed, and neither can be combined with any other # change (editing a field or assigning an owner). This matches the # admin interface, which locks every field except Is active, so the # admin, the API and the docs tell the same story. allowed = ( - keys <= {"is_active", "owner"} + changed_keys <= {"is_active", "owner"} and (not owner_present or is_owner_unassignment) and (reenabling or is_owner_unassignment) ) @@ -229,9 +240,7 @@ def update(self, instance, validated_data): class OrgUserCustomPrimarykeyRelatedField(serializers.PrimaryKeyRelatedField): default_error_messages = { - "does_not_exist": _( - 'Organization with pk "{pk_value}" does not exist or is disabled.' - ), + "does_not_exist": DISABLED_ORGANIZATION_ERROR_MESSAGE, } def get_queryset(self): diff --git a/openwisp_users/base/models.py b/openwisp_users/base/models.py index 18c0b1a90..ea84e81ff 100644 --- a/openwisp_users/base/models.py +++ b/openwisp_users/base/models.py @@ -518,12 +518,13 @@ def clean(self): # disabled organization must not fail. db_values = ( self._meta.model.objects.filter(pk=self.pk) - .values("organization_id", "is_admin") + .values("organization_id", "is_admin", "user_id") .first() ) if db_values is None or ( db_values["organization_id"] != self.organization_id or db_values["is_admin"] != self.is_admin + or db_values["user_id"] != self.user_id ): raise ValidationError( _("Memberships of a disabled organization cannot be modified.") diff --git a/openwisp_users/multitenancy.py b/openwisp_users/multitenancy.py index 99d34680b..51b3fa5de 100644 --- a/openwisp_users/multitenancy.py +++ b/openwisp_users/multitenancy.py @@ -92,7 +92,7 @@ def has_change_permission(self, request, obj=None): def has_add_permission(self, request, *args, **kwargs): """ - Hide the Add button from operators who manage no active organization: + Hide the Add button from admins who manage no active organization: the organization dropdown would be empty and the form could never be submitted. Does not apply to the user admin or to models without an organization (directly or through ``multitenant_parent``). @@ -106,22 +106,15 @@ def has_add_permission(self, request, *args, **kwargs): and self.model != User and not request.user.organizations_managed ): - org_field = ( - self.model._meta.get_field("organization") - if hasattr(self.model, "organization") - else None - ) - # If the model has a required organization field (OrgMixin, not - # ShareableOrgMixin which allows null/blank), the dropdown would be - # empty — block. If it's optional or reached through a parent, - # the form can still be submitted without picking an org. - if org_field is not None: - return False - if org_field is None and self.multitenant_parent: + # Any model with an organization field (directly, or reached + # through multitenant_parent) is blocked: _edit_form() makes the + # field required for non-superusers, so the form could not be + # submitted without an active organization to pick anyway. + if hasattr(self.model, "organization") or self.multitenant_parent: return False return super().has_add_permission(request, *args, **kwargs) - def _edit_form(self, request, form): + def _edit_form(self, request, form, obj=None): """ Modifies the form querysets as follows; if current user is not superuser: @@ -131,13 +124,24 @@ def _edit_form(self, request, form): * do not allow organization field to be empty (shared org) else show everything Organization choices always exclude disabled organizations, - superusers included. + superusers included, except an admin that opted out of write + protection (``disabled_organization_write_protection = False``) + keeps the edited object's own disabled organization selectable, + or the form could never be saved. """ fields = form.base_fields user = request.user org_field = fields.get("organization") + keep_disabled_org_pk = None + if not self.disabled_organization_write_protection and obj is not None: + organization = self._get_object_organization(obj) + if organization is not None and not organization.is_active: + keep_disabled_org_pk = organization.pk if org_field: - org_field.queryset = org_field.queryset.filter(is_active=True) + allowed = Q(is_active=True) + if keep_disabled_org_pk is not None: + allowed |= Q(pk=keep_disabled_org_pk) + org_field.queryset = org_field.queryset.filter(allowed) if user.is_superuser and org_field and not org_field.required: org_field.empty_label = SHARED_SYSTEMWIDE_LABEL elif not user.is_superuser: @@ -145,7 +149,10 @@ def _edit_form(self, request, form): # organizations relation; # may be readonly and not present in field list if org_field: - org_field.queryset = org_field.queryset.filter(pk__in=orgs_pk) + managed = Q(pk__in=orgs_pk) + if keep_disabled_org_pk is not None: + managed |= Q(pk=keep_disabled_org_pk) + org_field.queryset = org_field.queryset.filter(managed) org_field.empty_label = None org_field.required = True # other relations @@ -160,7 +167,7 @@ def _edit_form(self, request, form): def get_form(self, request, obj=None, **kwargs): form = super().get_form(request, obj, **kwargs) - self._edit_form(request, form) + self._edit_form(request, form, obj) return form def get_formset(self, request, obj=None, **kwargs): diff --git a/openwisp_users/tests/test_api/test_api.py b/openwisp_users/tests/test_api/test_api.py index 7747740a0..2c473b04f 100644 --- a/openwisp_users/tests/test_api/test_api.py +++ b/openwisp_users/tests/test_api/test_api.py @@ -1,13 +1,10 @@ -from unittest import mock - import django from allauth.account.models import EmailAddress from django.contrib import auth from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission from django.core import mail -from django.core.exceptions import ValidationError as DjangoValidationError -from django.test import TestCase +from django.test import TestCase, TransactionTestCase from django.urls import reverse from django.utils.timezone import localdate, timedelta from swapper import load_model @@ -141,6 +138,11 @@ def test_patch_disabled_organization_reenable_api(self): self.assertTrue(org1.is_active) def test_reenable_disabled_organization_with_field_edit_api(self): + """ + Re-enabling (is_active) and editing another field in the same + request is rejected: re-enabling and editing must be two separate + requests, matching the admin and the docs. + """ org1 = self._get_org() org1.is_active = False org1.save() @@ -154,6 +156,27 @@ def test_reenable_disabled_organization_with_field_edit_api(self): self.assertEqual(org1.is_active, False) self.assertEqual(org1.name, "test org") + def test_reenable_disabled_organization_via_put_api(self): + org1 = self._get_org() + org1.is_active = False + org1.save() + path = reverse("users:organization_detail", args=(org1.pk,)) + # a PUT always resends every required field, "name" included; since + # its value is unchanged it must not count as an edit and block the + # re-enable, the way a read-modify-write client would use PUT + data = { + "name": org1.name, + "is_active": True, + "slug": org1.slug, + "description": org1.description, + "email": org1.email, + "url": org1.url, + } + response = self.client.put(path, data, content_type="application/json") + self.assertEqual(response.status_code, 200) + org1.refresh_from_db() + self.assertTrue(org1.is_active) + def test_create_organization_owner_api(self): user1 = self._create_user(username="user1", email="user1@email.com") org1 = self._create_org(name="org1") @@ -682,41 +705,6 @@ def test_create_user_with_group_org_user_api(self): r = self.client.post(path, data, content_type="application/json") self.assertEqual(r.status_code, 201) - def test_create_user_organization_users_disabled_org_api(self): - path = reverse("users:user_list") - org1 = self._create_org(name="disabled-org", is_active=False) - data = { - "username": "tester", - "email": "tester@test.com", - "password": "password", - "organization_users": {"is_admin": False, "organization": org1.pk}, - } - r = self.client.post(path, data, content_type="application/json") - self.assertEqual(r.status_code, 400) - self.assertEqual(User.objects.filter(username="tester").count(), 0) - self.assertEqual(OrganizationUser.objects.filter(organization=org1).count(), 0) - - def test_create_user_membership_failure_rolls_back_user_api(self): - # A membership validation failure after the user row is written must - # roll the user back instead of leaving a half-created account behind. - path = reverse("users:user_list") - org1 = self._get_org() - data = { - "username": "rollbackuser", - "email": "rollbackuser@test.com", - "password": "password", - "organization_users": {"is_admin": False, "organization": org1.pk}, - } - with mock.patch.object( - OrganizationUser, - "full_clean", - side_effect=DjangoValidationError("membership boom"), - ): - r = self.client.post(path, data, content_type="application/json") - self.assertEqual(r.status_code, 400) - self.assertEqual(User.objects.filter(username="rollbackuser").count(), 0) - self.assertEqual(OrganizationUser.objects.filter(organization=org1).count(), 0) - def test_post_with_no_email(self): path = reverse("users:user_list") data = {"username": "", "email": "", "password": ""} @@ -992,3 +980,25 @@ def test_expiration_date_none_api(self): self.assertIsNone(r.data["expiration_date"]) user.refresh_from_db() self.assertIsNone(user.expiration_date) + + +class TestUsersApiTransaction(TestOrganizationMixin, TransactionTestCase): + def setUp(self): + self.client.force_login(self._get_admin()) + + def test_create_user_organization_users_disabled_org_api(self): + # A membership validation failure (here, the disabled-organization + # guard) after the user row is written must roll the user back + # instead of leaving a half-created account behind. + path = reverse("users:user_list") + org1 = self._create_org(name="disabled-org", is_active=False) + data = { + "username": "tester", + "email": "tester@test.com", + "password": "password", + "organization_users": {"is_admin": False, "organization": org1.pk}, + } + r = self.client.post(path, data, content_type="application/json") + self.assertEqual(r.status_code, 400) + self.assertEqual(User.objects.filter(username="tester").count(), 0) + self.assertEqual(OrganizationUser.objects.filter(organization=org1).count(), 0) diff --git a/openwisp_users/tests/test_models.py b/openwisp_users/tests/test_models.py index 4455eaaf5..be2d0f60b 100644 --- a/openwisp_users/tests/test_models.py +++ b/openwisp_users/tests/test_models.py @@ -468,6 +468,21 @@ def test_organization_user_clean_disabled_organization(self): # Django admin cannot save a user who has a disabled-org membership org_user.full_clean() + with self.subTest("reassign the user of a disabled organization membership"): + org = self._create_org(name="test-org-reassign-user") + user = self._create_user(username="user6", email="user6@example.com") + other_user = self._create_user(username="user7", email="user7@example.com") + org_user = self._create_org_user(organization=org, user=user) + org.is_active = False + org.save() + org_user.refresh_from_db() + org_user.user = other_user + with self.assertRaisesMessage( + ValidationError, + "Memberships of a disabled organization cannot be modified.", + ): + org_user.full_clean() + def test_organization_owner_clean_disabled_organization(self): with self.subTest("assign an owner to a disabled organization"): org = self._create_org(name="disabled-org-owner") diff --git a/tests/testapp/admin.py b/tests/testapp/admin.py index e565f6486..2c2d6d4fd 100644 --- a/tests/testapp/admin.py +++ b/tests/testapp/admin.py @@ -68,8 +68,13 @@ class TagAdmin(BaseAdmin): pass +class LibraryParentAdmin(MultitenantAdminMixin, admin.ModelAdmin): + # Library has no organization field; it is reached through its Book parent + multitenant_parent = "book" + + admin.site.register(Shelf, ShelfAdmin) admin.site.register(Book, BookAdmin) admin.site.register(Template, TemplateAdmin) -admin.site.register(Library) +admin.site.register(Library, LibraryParentAdmin) admin.site.register(Tag, TagAdmin) diff --git a/tests/testapp/tests/test_multitenancy.py b/tests/testapp/tests/test_multitenancy.py index f11050d8c..988fdc71a 100644 --- a/tests/testapp/tests/test_multitenancy.py +++ b/tests/testapp/tests/test_multitenancy.py @@ -6,16 +6,19 @@ from openwisp_users.multitenancy import MultitenantAdminMixin -from ..admin import ShelfAdmin +from ..admin import LibraryParentAdmin, ShelfAdmin from ..models import Book, Library, Shelf from .mixins import TestMultitenancyMixin User = get_user_model() -class LibraryParentAdmin(MultitenantAdminMixin, admin.ModelAdmin): - # Library has no organization field; it is reached through its Book parent - multitenant_parent = "book" +class ShelfDisabledOrgWriteAllowedAdmin(MultitenantAdminMixin, admin.ModelAdmin): + # dedicated admin used only to test the disabled_organization_write_protection + # opt-out; kept separate from ShelfAdmin so its default (protected) + # behaviour stays covered by the other tests in this file + disabled_organization_write_protection = False + fields = ["name", "organization"] class TestMultitenancy(TestMultitenancyMixin, TestCase): @@ -155,3 +158,30 @@ def test_add_permission_hidden_without_active_managed_org(self): self._create_org_user(user=operator, organization=active_org, is_admin=True) request.user = User.objects.get(pk=operator.pk) self.assertEqual(shelf_admin.has_add_permission(request), True) + + def test_disabled_organization_write_protection_opt_out(self): + org = self._get_org() + shelf = self._create_shelf(name="opt-out-shelf", organization=org) + org.is_active = False + org.save() + shelf_admin = ShelfDisabledOrgWriteAllowedAdmin(Shelf, admin.site) + request = RequestFactory().get("/") + request.user = self._get_admin() + + with self.subTest("change permission is not blocked for the opted-out admin"): + self.assertEqual(shelf_admin.has_change_permission(request, shelf), True) + + with self.subTest("the disabled organization stays in the field's choices"): + form_class = shelf_admin.get_form(request, shelf) + org_field = form_class.base_fields["organization"] + self.assertIn(org.pk, org_field.queryset.values_list("pk", flat=True)) + + with self.subTest("the form can still be saved"): + form_class = shelf_admin.get_form(request, shelf) + form = form_class( + data={"name": shelf.name, "organization": org.pk}, instance=shelf + ) + self.assertTrue(form.is_valid(), form.errors) + form.save() + shelf.refresh_from_db() + self.assertEqual(shelf.organization_id, org.pk) From 260285eb6609b77c8e3df114f64f277462ad1a08 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Wed, 15 Jul 2026 19:17:29 +0530 Subject: [PATCH 04/34] [docs] Updated docs --- docs/developer/admin-utils.rst | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/developer/admin-utils.rst b/docs/developer/admin-utils.rst index 8afd015c4..f8fb0f52b 100644 --- a/docs/developer/admin-utils.rst +++ b/docs/developer/admin-utils.rst @@ -59,7 +59,10 @@ attribute, which defaults to ``True``. Set it to ``False`` on a specific The ``organization`` form field's queryset also excludes disabled organizations for everyone, superusers included, so a disabled -organization can never be *selected* for a new or existing object either. +organization can never be *selected* for a new object. The one exception +is an object that already belongs to a disabled organization on a +``ModelAdmin`` with the opt-out set: its own (disabled) organization stays +selectable in the field so the existing value can still be saved. ``MultitenantOrgFilter`` ------------------------ From 42335713f5b1f3baeb935f9f38d9522f8b8c76ef Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Wed, 15 Jul 2026 19:42:23 +0530 Subject: [PATCH 05/34] [fix] Fixed selenium tests --- tests/testapp/tests/test_selenium.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/testapp/tests/test_selenium.py b/tests/testapp/tests/test_selenium.py index 10ab572bb..e1a8942b5 100644 --- a/tests/testapp/tests/test_selenium.py +++ b/tests/testapp/tests/test_selenium.py @@ -3,6 +3,7 @@ from django.db.models import Q from django.test import tag from django.urls import reverse +from selenium.common.exceptions import TimeoutException from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.select import Select @@ -25,6 +26,18 @@ def setUp(self): username=self.admin_username, password=self.admin_password ) + def logout(self, driver=None): + super().logout(driver) + driver = driver or self.web_driver + try: + WebDriverWait(driver, 5).until( + EC.url_to_be(f"{self.live_server_url}{reverse('admin:logout')}") + ) + except TimeoutException: + self.fail( + "Browser failed to logout the user: URL did not change to logout page" + ) + def _test_multitenant_autocomplete_org_field( self, username, password, path, visible, hidden ): From 840e88b9ee39662cc97f95832b637b745c324391 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Thu, 16 Jul 2026 01:44:15 +0530 Subject: [PATCH 06/34] [fix] Fixed selenium tests --- tests/testapp/tests/test_selenium.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/testapp/tests/test_selenium.py b/tests/testapp/tests/test_selenium.py index e1a8942b5..def7d54e5 100644 --- a/tests/testapp/tests/test_selenium.py +++ b/tests/testapp/tests/test_selenium.py @@ -64,7 +64,9 @@ def test_book_add_form_organization_field(self): ) administrator.user_permissions.add( *Permission.objects.filter( - Q(codename__contains="shelf") | Q(codename="view_organization") + Q(codename__contains="shelf") + | Q(codename="view_organization") + | Q(codename__contains="book") ).values_list("id", flat=True), ) From 02b39e68c97fe84c2de711a5377146fc4a44b558 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Mon, 3 Aug 2026 18:39:35 +0530 Subject: [PATCH 07/34] [fix] Requested changes --- docs/developer/admin-utils.rst | 6 ++ .../developer/django-rest-framework-utils.rst | 7 ++ docs/user/basic-concepts.rst | 2 +- openwisp_users/admin.py | 17 +++- openwisp_users/api/permissions.py | 8 +- openwisp_users/api/serializers.py | 23 +++-- .../openwisp-users/js/org-autocomplete.js | 96 ++++++++++--------- openwisp_users/tests/test_admin.py | 35 ++++++- openwisp_users/tests/test_models.py | 38 +++++++- tests/testapp/admin.py | 10 +- tests/testapp/tests/mixins.py | 3 +- tests/testapp/tests/test_selenium.py | 13 +++ 12 files changed, 195 insertions(+), 63 deletions(-) diff --git a/docs/developer/admin-utils.rst b/docs/developer/admin-utils.rst index f8fb0f52b..5f6fe255e 100644 --- a/docs/developer/admin-utils.rst +++ b/docs/developer/admin-utils.rst @@ -64,6 +64,12 @@ is an object that already belongs to a disabled organization on a ``ModelAdmin`` with the opt-out set: its own (disabled) organization stays selectable in the field so the existing value can still be saved. +The organization admin extends this protection to its **inlines**: when an +organization is disabled, every inline attached to the organization change +page becomes read-only, while deletion of the inline rows stays available. +An inline can opt out by setting ``disabled_organization_write_protection += False`` on its class. + ``MultitenantOrgFilter`` ------------------------ diff --git a/docs/developer/django-rest-framework-utils.rst b/docs/developer/django-rest-framework-utils.rst index 122ed6dab..12ac57a60 100644 --- a/docs/developer/django-rest-framework-utils.rst +++ b/docs/developer/django-rest-framework-utils.rst @@ -141,6 +141,13 @@ This object-level permission class blocks updating an object that belongs to a :ref:`disabled organization `. Read (safe methods) and ``DELETE`` remain allowed. +The object's organization is located through the view's +``organization_field`` attribute (default ``"organization"``). If that +traversal fails, for example because ``organization_field`` is misspelled +or points to a relation that does not exist, the class **fails closed** +and denies the write. A view whose objects are genuinely +not tied to an organization must therefore opt out explicitly. + .. important:: ``DisabledOrgReadOnly`` guards **updates only**. It implements diff --git a/docs/user/basic-concepts.rst b/docs/user/basic-concepts.rst index 12360acc4..fe4cd94e9 100644 --- a/docs/user/basic-concepts.rst +++ b/docs/user/basic-concepts.rst @@ -161,7 +161,7 @@ organization). Disabling an organization does not delete anything: all of its data, including users, memberships, and related objects, remains fully -**readable** and **deletable**. What changes is: +**readable** and **deletable** for superusers. What changes is: - **No new object can be created for a disabled organization**, and **existing objects belonging to it cannot be modified**, superusers diff --git a/openwisp_users/admin.py b/openwisp_users/admin.py index 43349467a..2d15a856b 100644 --- a/openwisp_users/admin.py +++ b/openwisp_users/admin.py @@ -633,7 +633,9 @@ class OrganizationAdmin( def get_inline_instances(self, request, obj=None): """ - Remove OrganizationOwnerInline from organization add form + Remove OrganizationOwnerInline from the organization add form, and, + when the organization is disabled, make every inline write-protected + (no add, no change) while keeping deletion available. """ inlines = super().get_inline_instances(request, obj).copy() if not obj: @@ -641,8 +643,21 @@ def get_inline_instances(self, request, obj=None): if isinstance(inline, OrganizationOwnerInline): inlines.remove(inline) break + return inlines + if not obj.is_active: + for inline in inlines: + if getattr(inline, "disabled_organization_write_protection", True): + self._make_inline_disabled_org_readonly(inline) return inlines + @staticmethod + def _make_inline_disabled_org_readonly(inline): + # Deny add and change on this inline instance while leaving delete + # untouched, so a disabled organization's related objects can be + # removed but not created or modified. + inline.has_add_permission = lambda request, obj=None: False + inline.has_change_permission = lambda request, obj=None: False + def has_change_permission(self, request, obj=None): """ Allow only managers and superuser to change organization diff --git a/openwisp_users/api/permissions.py b/openwisp_users/api/permissions.py index af5b7389f..a31700f12 100644 --- a/openwisp_users/api/permissions.py +++ b/openwisp_users/api/permissions.py @@ -115,8 +115,12 @@ def has_object_permission(self, request, view, obj): try: organization = self.get_object_organization(view, obj) except AttributeError: - # object has no organization field, rule not applicable - return True + # A broken or misspelled organization_field must not fail open, + # so a misconfiguration cannot silently grant write access. Views + # that are genuinely not organization-bound opt out explicitly with + # allow_disabled_organization_writes = True instead of relying on + # this path. + return False return organization is None or organization.is_active diff --git a/openwisp_users/api/serializers.py b/openwisp_users/api/serializers.py index d06a11a5e..dbccaf208 100644 --- a/openwisp_users/api/serializers.py +++ b/openwisp_users/api/serializers.py @@ -172,7 +172,7 @@ def update(self, instance, validated_data): ): org_user = org_owner.get("organization_user") with transaction.atomic(): - org_owner = OrganizationOwner.objects.create( + org_owner = OrganizationOwner( organization=instance, organization_user=org_user ) _full_clean_or_raise(org_owner) @@ -189,7 +189,7 @@ def update(self, instance, validated_data): org_user = org_owner.get("organization_user") with transaction.atomic(): existing_owner.first().delete() - org_owner = OrganizationOwner.objects.create( + org_owner = OrganizationOwner( organization=instance, organization_user=org_user ) _full_clean_or_raise(org_owner) @@ -345,7 +345,7 @@ def create(self, validated_data): with transaction.atomic(): instance = self.instance or self.Meta.model(**validated_data) instance.set_password(password) - instance.full_clean() + _full_clean_or_raise(instance) instance.save() if group_data: @@ -427,12 +427,17 @@ def update(self, instance, validated_data): except OrganizationUser.DoesNotExist: pass if org_user: - if org_user.is_admin != org_user_data.get("is_admin"): - org_user.is_admin = org_user_data["is_admin"] - _full_clean_or_raise(org_user) - org_user.save() - else: - org_user.delete() + # Explicit contract for an existing membership: + # - is_admin omitted: leave the membership unchanged + # - is_admin sent and changed: update it; + # - is_admin sent unchanged -> remove the membership + if "is_admin" in org_user_data: + if org_user.is_admin != org_user_data["is_admin"]: + org_user.is_admin = org_user_data["is_admin"] + _full_clean_or_raise(org_user) + org_user.save() + else: + org_user.delete() else: org_user_data["user"] = instance org_user_instance = OrganizationUser(**org_user_data) diff --git a/openwisp_users/static/openwisp-users/js/org-autocomplete.js b/openwisp_users/static/openwisp-users/js/org-autocomplete.js index 08b78c7b4..962728b53 100644 --- a/openwisp_users/static/openwisp-users/js/org-autocomplete.js +++ b/openwisp_users/static/openwisp-users/js/org-autocomplete.js @@ -7,58 +7,66 @@ // Therefore, the backend uses "null" id for Systemwide shared // objects. This causes issues on submitting forms because // Django expects an empty string (for None) or a UUID string. - // Hence, we need to update the value of selected option before - // submission of form. - var formElement = $("select#id_organization"); - while (formElement.prop("tagName") !== "FORM") { - formElement = formElement.parent(); - } - formElement.submit(function () { - var target = $("select#id_organization option:selected"); - if (target.val() === "null") { - target.val(""); - } + // Hence, we need to update the value of the selected option before + // submission of the form. + // + // Every organization autocomplete widget (top-level or inline) renders + // `data-field-name="organization"`, so we bind to each one instead of a + // single hardcoded id. + $("select[data-field-name='organization']").each(function () { + var orgSelect = $(this); + orgSelect.closest("form").on("submit", function () { + var selected = orgSelect.find("option:selected"); + if (selected.val() === "null") { + selected.val(""); + } + }); }); - if (!$("select#id_organization").val()) { - var orgField = $("#id_organization"), - pathName = window.location.pathname.split("/"); - // If the field is rendered empty on a change form, then the - // the object is shared systemwide (no organization). - if (pathName[pathName.length - 2] == "change") { - orgField.val("null"); - orgField.trigger("change"); - return; - } + // Auto-selection only applies to the single top-level organization field + // when it is still empty (e.g. add forms or systemwide-shared objects). Skip + // inline organization selects and forms where an organization is already set. + var orgField = $("select#id_organization"); + if (!orgField.length || orgField.val()) { + return; + } + + var pathName = window.location.pathname.split("/"); + // If the field is rendered empty on a change form, then the + // object is shared systemwide (no organization). + if (pathName[pathName.length - 2] == "change") { + orgField.val("null"); + orgField.trigger("change"); + return; + } - // If only one organization option is available, then select that - // organization automatically - $.ajax({ - url: orgField.data("ajax--url"), - data: { - app_label: orgField.data("app-label"), - model_name: orgField.data("model-name"), - field_name: orgField.data("field-name"), - }, - success: function (data) { - if (data.results.length === 1) { + // If only one organization option is available, then select that + // organization automatically. + $.ajax({ + url: orgField.data("ajax--url"), + data: { + app_label: orgField.data("app-label"), + model_name: orgField.data("model-name"), + field_name: orgField.data("field-name"), + }, + success: function (data) { + if (data.results.length === 1) { var option = new Option( data.results[0].text, data.results[0].id, true, true, ); - orgField.append(option).trigger("change"); - // manually trigger the `select2:select` event - orgField.trigger({ - type: "select2:select", - params: { - data: data.results[0], - }, - }); - } - }, - }); - } + orgField.append(option).trigger("change"); + // manually trigger the `select2:select` event + orgField.trigger({ + type: "select2:select", + params: { + data: data.results[0], + }, + }); + } + }, + }); }); })(django.jQuery); diff --git a/openwisp_users/tests/test_admin.py b/openwisp_users/tests/test_admin.py index f3cf035aa..70d581a52 100644 --- a/openwisp_users/tests/test_admin.py +++ b/openwisp_users/tests/test_admin.py @@ -13,7 +13,7 @@ from django.core.exceptions import ValidationError from django.db import DEFAULT_DB_ALIAS from django.template.defaultfilters import date -from django.test import TestCase, override_settings +from django.test import RequestFactory, TestCase, override_settings from django.urls import reverse from django.utils.timezone import localdate, now, timedelta from freezegun import freeze_time @@ -2024,6 +2024,39 @@ def test_organization_owner_inline_disabled_organization(self): OrganizationOwner.objects.filter(pk=org_owner.pk).count(), 0 ) + def test_disabled_org_inlines_centrally_write_protected(self): + # OrganizationAdmin write-protects every inline attached to it when the + # organization is disabled, so downstream inlines inherit the guard without + # re-implementing it. + admin = self._get_admin() + request = RequestFactory().get("/") + request.user = admin + org_admin = OrganizationAdmin(Organization, django_admin.site) + active_org = self._create_org(name="active-inline-org") + disabled_org = self._create_org(name="disabled-inline-org", is_active=False) + + with self.subTest("active organization keeps inlines writable"): + for inline in org_admin.get_inline_instances(request, active_org): + self.assertEqual( + inline.has_change_permission(request, active_org), True + ) + + with self.subTest("disabled organization write-protects inlines"): + inlines = org_admin.get_inline_instances(request, disabled_org) + self.assertNotEqual(inlines, []) + for inline in inlines: + self.assertEqual( + inline.has_add_permission(request, disabled_org), False + ) + self.assertEqual( + inline.has_change_permission(request, disabled_org), False + ) + # deletion of the disabled organization's related rows stays + # possible + self.assertEqual( + inline.has_delete_permission(request, disabled_org), True + ) + def test_organization_user_admin_disabled_organization(self): admin = self._get_admin() self.client.force_login(admin) diff --git a/openwisp_users/tests/test_models.py b/openwisp_users/tests/test_models.py index be2d0f60b..f4a5a2a31 100644 --- a/openwisp_users/tests/test_models.py +++ b/openwisp_users/tests/test_models.py @@ -442,8 +442,9 @@ def test_organization_user_clean_disabled_organization(self): ): org_user.full_clean() # deleting the row must still work + pk = org_user.pk org_user.delete() - self.assertEqual(OrganizationUser.objects.filter(pk=org_user.pk).count(), 0) + self.assertEqual(OrganizationUser.objects.filter(pk=pk).count(), 0) with self.subTest("move an existing membership to a disabled organization"): active_org = self._create_org(name="active-org") @@ -457,6 +458,21 @@ def test_organization_user_clean_disabled_organization(self): ): org_user.full_clean() + with self.subTest("move a disabled-org membership to an active organization"): + org = self._create_org(name="test-org-move-out") + active_org = self._create_org(name="active-org-move-target") + user = self._create_user(username="user8", email="user8@example.com") + org_user = self._create_org_user(organization=org, user=user) + org.is_active = False + org.save() + org_user.refresh_from_db() + with self.assertRaisesMessage( + ValidationError, + "Memberships of a disabled organization cannot be modified.", + ): + org_user.organization = active_org + org_user.full_clean() + with self.subTest("unchanged membership of a disabled organization passes"): org = self._create_org(name="test-org-noop") user = self._create_user(username="user5", email="user5@example.com") @@ -505,10 +521,26 @@ def test_organization_owner_clean_disabled_organization(self): ) org.is_active = False org.save() + pk = org_owner.pk org_owner.delete() - self.assertEqual( - OrganizationOwner.objects.filter(pk=org_owner.pk).count(), 0 + self.assertEqual(OrganizationOwner.objects.filter(pk=pk).count(), 0) + + with self.subTest("move a disabled-org owner to an active organization"): + org = self._create_org(name="test-org-owner-move-out") + active_org = self._create_org(name="active-org-owner-target") + user = self._create_user(username="user9", email="user9@example.com") + org_user = self._create_org_user(organization=org, user=user) + org_owner = self._create_org_owner( + organization=org, organization_user=org_user ) + org.is_active = False + org.save() + org_owner.refresh_from_db() + with self.assertRaisesMessage( + ValidationError, "Cannot assign an owner to a disabled organization." + ): + org_owner.organization = active_org + org_owner.full_clean() with self.subTest("unchanged owner of a disabled organization passes"): org = self._create_org(name="test-org-owner-noop") diff --git a/tests/testapp/admin.py b/tests/testapp/admin.py index 2c2d6d4fd..81a65e4e8 100644 --- a/tests/testapp/admin.py +++ b/tests/testapp/admin.py @@ -7,7 +7,7 @@ MultitenantRelatedOrgFilter, ) -from .models import Book, Library, Shelf, Tag, Template +from .models import Book, Config, Library, Shelf, Tag, Template class BaseAdmin(MultitenantAdminMixin, admin.ModelAdmin): @@ -73,8 +73,16 @@ class LibraryParentAdmin(MultitenantAdminMixin, admin.ModelAdmin): multitenant_parent = "book" +class ConfigAdmin(BaseAdmin): + # Dedicated admin used to test the disabled_organization_write_protection + # opt-out through the admin URLs + disabled_organization_write_protection = False + fields = ["name", "organization", "template"] + + admin.site.register(Shelf, ShelfAdmin) admin.site.register(Book, BookAdmin) admin.site.register(Template, TemplateAdmin) admin.site.register(Library, LibraryParentAdmin) admin.site.register(Tag, TagAdmin) +admin.site.register(Config, ConfigAdmin) diff --git a/tests/testapp/tests/mixins.py b/tests/testapp/tests/mixins.py index 4f323cd49..0d0459934 100644 --- a/tests/testapp/tests/mixins.py +++ b/tests/testapp/tests/mixins.py @@ -1,10 +1,11 @@ from openwisp_users.tests.test_api import AuthenticationMixin +from openwisp_users.tests.test_api.utils import TestDisabledOrgApiMixin from openwisp_users.tests.utils import TestMultitenantAdminMixin from .. import CreateMixin class TestMultitenancyMixin( - CreateMixin, TestMultitenantAdminMixin, AuthenticationMixin + CreateMixin, TestMultitenantAdminMixin, TestDisabledOrgApiMixin, AuthenticationMixin ): pass diff --git a/tests/testapp/tests/test_selenium.py b/tests/testapp/tests/test_selenium.py index def7d54e5..f4c130661 100644 --- a/tests/testapp/tests/test_selenium.py +++ b/tests/testapp/tests/test_selenium.py @@ -1,3 +1,4 @@ +from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission from django.contrib.staticfiles.testing import StaticLiveServerTestCase from django.db.models import Q @@ -15,6 +16,7 @@ from .mixins import TestMultitenancyMixin Organization = load_model("openwisp_users", "Organization") +User = get_user_model() @tag("selenium_tests") @@ -153,3 +155,14 @@ def test_shelf_add_form_organization_field(self): self.assertEqual(len(org_select.all_selected_options), 1) self.assertEqual(org_select.first_selected_option.text, org1.name) self.logout() + + def test_user_add_form_does_not_hang(self): + path = reverse(f"admin:{User._meta.app_label}_user_add") + self.login(username=self.admin_username, password=self.admin_password) + self.open(path) + WebDriverWait(self.web_driver, 5).until( + EC.presence_of_element_located( + (By.CSS_SELECTOR, "select[id$='-organization'] + span.select2") + ) + ) + self.logout() From c3d57f982b766cc6ce8879a704188bc10080db26 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Wed, 5 Aug 2026 17:53:13 +0530 Subject: [PATCH 08/34] [fix] Fixed tests --- .../developer/django-rest-framework-utils.rst | 4 +- openwisp_users/admin.py | 17 +- openwisp_users/api/mixins.py | 14 +- openwisp_users/multitenancy.py | 20 ++ .../openwisp-users/js/org-autocomplete.js | 7 +- openwisp_users/tests/test_admin.py | 33 +-- openwisp_users/tests/test_api/__init__.py | 197 ++++++++++++- openwisp_users/tests/test_api/test_api.py | 49 +++- openwisp_users/tests/utils.py | 269 +++++++++++++++++- tests/testapp/__init__.py | 8 + tests/testapp/admin.py | 10 + tests/testapp/tests/mixins.py | 3 +- tests/testapp/tests/test_multitenancy.py | 142 +++++++-- .../testapp/tests/test_permission_classes.py | 241 ++++++++++++++-- 14 files changed, 899 insertions(+), 115 deletions(-) diff --git a/docs/developer/django-rest-framework-utils.rst b/docs/developer/django-rest-framework-utils.rst index 12ac57a60..edf7928a3 100644 --- a/docs/developer/django-rest-framework-utils.rst +++ b/docs/developer/django-rest-framework-utils.rst @@ -145,8 +145,8 @@ The object's organization is located through the view's ``organization_field`` attribute (default ``"organization"``). If that traversal fails, for example because ``organization_field`` is misspelled or points to a relation that does not exist, the class **fails closed** -and denies the write. A view whose objects are genuinely -not tied to an organization must therefore opt out explicitly. +and denies the write. A view whose objects are genuinely not tied to an +organization must therefore opt out explicitly. .. important:: diff --git a/openwisp_users/admin.py b/openwisp_users/admin.py index 2d15a856b..830085348 100644 --- a/openwisp_users/admin.py +++ b/openwisp_users/admin.py @@ -633,9 +633,7 @@ class OrganizationAdmin( def get_inline_instances(self, request, obj=None): """ - Remove OrganizationOwnerInline from the organization add form, and, - when the organization is disabled, make every inline write-protected - (no add, no change) while keeping deletion available. + Remove OrganizationOwnerInline from the organization add form. """ inlines = super().get_inline_instances(request, obj).copy() if not obj: @@ -643,21 +641,8 @@ def get_inline_instances(self, request, obj=None): if isinstance(inline, OrganizationOwnerInline): inlines.remove(inline) break - return inlines - if not obj.is_active: - for inline in inlines: - if getattr(inline, "disabled_organization_write_protection", True): - self._make_inline_disabled_org_readonly(inline) return inlines - @staticmethod - def _make_inline_disabled_org_readonly(inline): - # Deny add and change on this inline instance while leaving delete - # untouched, so a disabled organization's related objects can be - # removed but not created or modified. - inline.has_add_permission = lambda request, obj=None: False - inline.has_change_permission = lambda request, obj=None: False - def has_change_permission(self, request, obj=None): """ Allow only managers and superuser to change organization diff --git a/openwisp_users/api/mixins.py b/openwisp_users/api/mixins.py index b98212aa2..dab29f5e1 100644 --- a/openwisp_users/api/mixins.py +++ b/openwisp_users/api/mixins.py @@ -190,8 +190,20 @@ def filter_fields(self): self.fields[field].queryset = queryset continue if is_superuser_or_anonymous: + try: + self.fields[field].queryset = self.fields[field].queryset.filter( + Q(**{f"{self.org_field}__is_active": True}) + | Q(**{f"{self.org_field}__isnull": True}) + ) + except AttributeError: + pass continue - conditions = Q(**{self.organization_lookup: organization_filter}) + conditions = Q( + **{ + self.organization_lookup: organization_filter, + f"{self.org_field}__is_active": True, + } + ) if self.include_shared: conditions |= Q(organization__isnull=True) try: diff --git a/openwisp_users/multitenancy.py b/openwisp_users/multitenancy.py index 51b3fa5de..00788f67a 100644 --- a/openwisp_users/multitenancy.py +++ b/openwisp_users/multitenancy.py @@ -67,6 +67,8 @@ def _get_object_organization(self, obj): ``multitenant_parent`` for models whose organization is reached through a parent (e.g. a Book through its Shelf). """ + if self.model.__name__ == "Organization": + return obj organization = getattr(obj, "organization", None) if organization is None and self.multitenant_parent: parent = obj @@ -90,6 +92,24 @@ def has_change_permission(self, request, obj=None): return False return super().has_change_permission(request, obj) + def get_inline_instances(self, request, obj=None): + """ + When the edited object belongs to a disabled organization, make + every inline write-protected (no add, no change) while keeping + deletion available. + """ + inlines = super().get_inline_instances(request, obj) + if obj is None or not self.disabled_organization_write_protection: + return inlines + organization = self._get_object_organization(obj) + if organization is None or organization.is_active: + return inlines + for inline in inlines: + if getattr(inline, "disabled_organization_write_protection", True): + inline.has_add_permission = lambda request, obj=None: False + inline.has_change_permission = lambda request, obj=None: False + return inlines + def has_add_permission(self, request, *args, **kwargs): """ Hide the Add button from admins who manage no active organization: diff --git a/openwisp_users/static/openwisp-users/js/org-autocomplete.js b/openwisp_users/static/openwisp-users/js/org-autocomplete.js index 962728b53..137c99013 100644 --- a/openwisp_users/static/openwisp-users/js/org-autocomplete.js +++ b/openwisp_users/static/openwisp-users/js/org-autocomplete.js @@ -51,12 +51,7 @@ }, success: function (data) { if (data.results.length === 1) { - var option = new Option( - data.results[0].text, - data.results[0].id, - true, - true, - ); + var option = new Option(data.results[0].text, data.results[0].id, true, true); orgField.append(option).trigger("change"); // manually trigger the `select2:select` event orgField.trigger({ diff --git a/openwisp_users/tests/test_admin.py b/openwisp_users/tests/test_admin.py index 70d581a52..786b89211 100644 --- a/openwisp_users/tests/test_admin.py +++ b/openwisp_users/tests/test_admin.py @@ -13,7 +13,7 @@ from django.core.exceptions import ValidationError from django.db import DEFAULT_DB_ALIAS from django.template.defaultfilters import date -from django.test import RequestFactory, TestCase, override_settings +from django.test import TestCase, override_settings from django.urls import reverse from django.utils.timezone import localdate, now, timedelta from freezegun import freeze_time @@ -29,6 +29,7 @@ from ..multitenancy import MultitenantAdminMixin from ..widgets import OrganizationAutocompleteSelect from .utils import ( + TestDisabledOrgAdminMixin, TestMultitenantAdminMixin, TestOrganizationMixin, TestUserAdditionalFieldsMixin, @@ -43,7 +44,7 @@ class TestUsersAdmin( AdminActionPermTestMixin, - TestOrganizationMixin, + TestDisabledOrgAdminMixin, TestUserAdditionalFieldsMixin, TestCase, ): @@ -2028,34 +2029,12 @@ def test_disabled_org_inlines_centrally_write_protected(self): # OrganizationAdmin write-protects every inline attached to it when the # organization is disabled, so downstream inlines inherit the guard without # re-implementing it. - admin = self._get_admin() - request = RequestFactory().get("/") - request.user = admin org_admin = OrganizationAdmin(Organization, django_admin.site) active_org = self._create_org(name="active-inline-org") disabled_org = self._create_org(name="disabled-inline-org", is_active=False) - - with self.subTest("active organization keeps inlines writable"): - for inline in org_admin.get_inline_instances(request, active_org): - self.assertEqual( - inline.has_change_permission(request, active_org), True - ) - - with self.subTest("disabled organization write-protects inlines"): - inlines = org_admin.get_inline_instances(request, disabled_org) - self.assertNotEqual(inlines, []) - for inline in inlines: - self.assertEqual( - inline.has_add_permission(request, disabled_org), False - ) - self.assertEqual( - inline.has_change_permission(request, disabled_org), False - ) - # deletion of the disabled organization's related rows stays - # possible - self.assertEqual( - inline.has_delete_permission(request, disabled_org), True - ) + self._test_disabled_org_admin_inline_readonly( + org_admin, disabled_org, active_obj=active_org + ) def test_organization_user_admin_disabled_organization(self): admin = self._get_admin() diff --git a/openwisp_users/tests/test_api/__init__.py b/openwisp_users/tests/test_api/__init__.py index 2efa2ba5f..4f125507f 100644 --- a/openwisp_users/tests/test_api/__init__.py +++ b/openwisp_users/tests/test_api/__init__.py @@ -1,7 +1,8 @@ from django.test import TestCase from django.urls import reverse -from openwisp_users.tests.utils import TestMultitenantAdminMixin +from openwisp_users.api.permissions import DisabledOrgReadOnly +from openwisp_users.tests.utils import TestDisabledOrgMixin, TestMultitenantAdminMixin class AuthenticationMixin: @@ -12,5 +13,197 @@ def _obtain_auth_token(self, username="operator", password="tester"): return response.data["token"] -class APITestCase(TestMultitenantAdminMixin, AuthenticationMixin, TestCase): +class TestDisabledOrgApiMixin(TestDisabledOrgMixin): + """ + Reusable assertions for the REST API's disabled-organization guard + (``DisabledOrgReadOnly`` plus the ``organization`` field filtering + performed by the ``FilterSerializerByOrganization`` subclasses), for + downstream OpenWISP modules to exercise against their own + org-scoped API views without re-implementing the request plumbing. + + Must be composed alongside ``AuthenticationMixin`` (for + ``_obtain_auth_token``), which every existing composite in this + codebase using this mixin already includes. + + Prerequisite (not enforced by this mixin): the serializer behind the + create payload must filter its ``organization`` field to active + organizations for the "create" assertion below to hold - one of + ``FilterSerializerByOrgManaged``/``Membership``/``Owned``, or an + equivalent explicit queryset, see + ``openwisp_users/api/mixins.py:FilterSerializerByOrganization``. + + Note: the default expectations assume the view uses + ``IsOrganizationManager`` in its ``permission_classes``. Once an + organization is disabled, it drops out of every user's + ``organizations_managed`` (see ``organizations_dict``), so an + "org_admin" who managed only that organization has an empty + ``organizations_managed`` list. ``IsOrganizationManager.has_permission()`` + blocks every request with 403 because the user no longer manages any + active organization. The superuser bypasses this check but is still + blocked by ``DisabledOrgReadOnly`` on update. This is why the two + roles have different default expectations below. + """ + + disabled_org_api_default_expectations = { + "superuser": { + "list": {"status": 200, "object_present": True}, + "retrieve": {"status": 200}, + "create": { + "status": 400, + "error_field": "organization", + "error_contains": "does not exist or is disabled", + }, + "update": { + "status": 403, + "unchanged": True, + "error_contains": str(DisabledOrgReadOnly.message), + }, + "delete": {"status": 204, "exists_after": False}, + }, + "org_admin": { + "list": {"status": 403}, + "retrieve": {"status": 403}, + "create": {"status": 403}, + "update": {"status": 403, "unchanged": True}, + "delete": {"status": 403, "exists_after": True}, + }, + } + + def _disabled_org_api_auth(self, user, mechanism="bearer", password="tester"): + if mechanism == "bearer": + token = self._obtain_auth_token(username=user.username, password=password) + return {"HTTP_AUTHORIZATION": f"Bearer {token}"} + if mechanism == "session": + self.client.force_login(user) + return {} + raise ValueError(f"Unknown auth mechanism: {mechanism!r}") + + def _test_disabled_org_api_list( + self, url, auth, obj, status=200, object_present=True, id_field="id" + ): + response = self.client.get(url, **auth) + self.assertEqual(response.status_code, status) + if status != 200: + return + data = response.data + results = data["results"] if isinstance(data, dict) else data + obj_id = str(getattr(obj, id_field)) + present = any(str(item.get(id_field)) == obj_id for item in results) + self.assertEqual(present, object_present) + + def _test_disabled_org_api_retrieve(self, url, auth, status=200): + response = self.client.get(url, **auth) + self.assertEqual(response.status_code, status) + + def _test_disabled_org_api_create( + self, + url, + auth, + payload, + status=400, + error_field="organization", + error_contains=None, + ): + response = self.client.post( + url, data=payload, content_type="application/json", **auth + ) + self.assertEqual(response.status_code, status) + if error_contains: + self.assertIn(error_contains, str(response.data[error_field][0])) + + def _test_disabled_org_api_update( + self, + url, + auth, + payload, + obj, + status=403, + unchanged=True, + unchanged_field="name", + error_contains=None, + methods=("put", "patch"), + ): + for method in methods: + with self.subTest(method=method): + if unchanged: + before = getattr(obj, unchanged_field) + response = getattr(self.client, method)( + url, data=payload, content_type="application/json", **auth + ) + self.assertEqual(response.status_code, status) + if error_contains: + self.assertEqual(str(response.data["detail"]), error_contains) + if unchanged: + obj.refresh_from_db() + self.assertEqual(getattr(obj, unchanged_field), before) + + def _test_disabled_org_api_delete( + self, url, auth, model, pk, status=204, exists_after=False + ): + response = self.client.delete(url, **auth) + self.assertEqual(response.status_code, status) + self.assertEqual(model.objects.filter(pk=pk).exists(), exists_after) + + def _test_disabled_org_api_crud( + self, + obj, + detail_url, + list_url=None, + create_payload=None, + update_payload=None, + roles=("org_admin", "superuser"), + operations=("list", "retrieve", "create", "update", "delete"), + org_admin_expected=None, + superuser_expected=None, + auth_mechanism="bearer", + unchanged_field="name", + organization=None, + ): + organization = organization or getattr(obj, "organization", None) + specs = { + "org_admin": { + **self.disabled_org_api_default_expectations["org_admin"], + **(org_admin_expected or {}), + }, + "superuser": { + **self.disabled_org_api_default_expectations["superuser"], + **(superuser_expected or {}), + }, + } + model = type(obj) + pk = obj.pk + for role in roles: + user = self._disabled_org_role_user(role, organization=organization) + auth = self._disabled_org_api_auth(user, mechanism=auth_mechanism) + for operation in operations: + with self.subTest(role=role, operation=operation): + spec = specs[role][operation] + if operation == "list": + self._test_disabled_org_api_list(list_url, auth, obj, **spec) + elif operation == "retrieve": + self._test_disabled_org_api_retrieve(detail_url, auth, **spec) + elif operation == "create": + self._test_disabled_org_api_create( + list_url, auth, create_payload, **spec + ) + elif operation == "update": + self._test_disabled_org_api_update( + detail_url, + auth, + update_payload, + obj, + unchanged_field=unchanged_field, + **spec, + ) + elif operation == "delete": + self._test_disabled_org_api_delete( + detail_url, auth, model, pk, **spec + ) + else: + raise ValueError(f"Unknown operation: {operation!r}") + + +class APITestCase( + TestMultitenantAdminMixin, TestDisabledOrgApiMixin, AuthenticationMixin, TestCase +): pass diff --git a/openwisp_users/tests/test_api/test_api.py b/openwisp_users/tests/test_api/test_api.py index 2c473b04f..9316769b1 100644 --- a/openwisp_users/tests/test_api/test_api.py +++ b/openwisp_users/tests/test_api/test_api.py @@ -1,3 +1,5 @@ +from unittest import mock + import django from allauth.account.models import EmailAddress from django.contrib import auth @@ -12,7 +14,10 @@ from openwisp_utils.tests import AssertNumQueriesSubTestMixin from ... import settings as app_settings -from ...api.serializers import OrganizationUserSerializer +from ...api.serializers import ( + OrganizationUserSerializer, + OrgUserCustomPrimarykeyRelatedField, +) from ..utils import TestOrganizationMixin Organization = load_model("openwisp_users", "Organization") @@ -183,7 +188,9 @@ def test_create_organization_owner_api(self): org1_user1 = self._create_org_user(user=user1, organization=org1) path = reverse("users:organization_detail", args=(org1.pk,)) data = {"owner": {"organization_user": org1_user1.pk}} - with self.assertNumQueries(18): + # building the owner and saving it once (instead of objects.create() + # followed by a redundant save()) removed two queries here + with self.assertNumQueries(16): r = self.client.patch(path, data, content_type="application/json") self.assertEqual(r.status_code, 200) self.assertEqual(r.data["owner"]["organization_user"], org1_user1.pk) @@ -283,7 +290,9 @@ def test_change_organizationowner_for_org(self): self.assertEqual(org1.owner.organization_user.id, org1_user1.id) path = reverse("users:organization_detail", args=(org1.pk,)) data = {"owner": {"organization_user": org1_user2.id}} - with self.assertNumQueries(27): + # building the new owner and saving it once (instead of objects.create() + # followed by a redundant save()) removed two queries here + with self.assertNumQueries(25): r = self.client.patch(path, data, content_type="application/json") org1.refresh_from_db() self.assertEqual(org1.owner.organization_user.id, org1_user2.id) @@ -830,6 +839,19 @@ def test_patch_resend_disabled_org_membership_preserves_it_api(self): OrganizationUser.objects.filter(user=user1, organization=org1).count(), 1 ) + def test_patch_user_org_membership_without_is_admin_preserves_it_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) + path = reverse("users:user_detail", args=(user1.pk,)) + # omitting is_admin must leave the membership unchanged instead of + # raising a KeyError (500) or deleting it implicitly + data = {"organization_users": [{"organization": org1.pk}]} + r = self.client.patch(path, data, content_type="application/json") + self.assertEqual(r.status_code, 200) + org_user = OrganizationUser.objects.get(user=user1, organization=org1) + self.assertEqual(org_user.is_admin, True) + def test_assign_user_to_groups_api(self): user = self._get_user() self.assertEqual(user.groups.count(), 0) @@ -987,9 +1009,13 @@ def setUp(self): self.client.force_login(self._get_admin()) def test_create_user_organization_users_disabled_org_api(self): - # A membership validation failure (here, the disabled-organization - # guard) after the user row is written must roll the user back - # instead of leaving a half-created account behind. + # A membership validation failure after the user row is written must + # roll the user back instead of leaving a half-created account behind. + # The membership field only accepts active organizations, so field + # validation would normally reject a disabled org before the user is + # ever created; patch its queryset to let field validation pass, so the + # model's clean() is what fails, inside the atomic block, after the + # user row has been written. This exercises the transaction rollback. path = reverse("users:user_list") org1 = self._create_org(name="disabled-org", is_active=False) data = { @@ -998,7 +1024,16 @@ def test_create_user_organization_users_disabled_org_api(self): "password": "password", "organization_users": {"is_admin": False, "organization": org1.pk}, } - r = self.client.post(path, data, content_type="application/json") + + # a real function (not a MagicMock) so DRF can still introspect + # get_queryset via its __func__ attribute + def get_all_orgs(self): + return Organization.objects.all() + + with mock.patch.object( + OrgUserCustomPrimarykeyRelatedField, "get_queryset", get_all_orgs + ): + r = self.client.post(path, data, content_type="application/json") self.assertEqual(r.status_code, 400) self.assertEqual(User.objects.filter(username="tester").count(), 0) self.assertEqual(OrganizationUser.objects.filter(organization=org1).count(), 0) diff --git a/openwisp_users/tests/utils.py b/openwisp_users/tests/utils.py index 6c8bbe27b..ec4696cff 100644 --- a/openwisp_users/tests/utils.py +++ b/openwisp_users/tests/utils.py @@ -2,6 +2,7 @@ from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission +from django.test import RequestFactory from django.urls import reverse from swapper import load_model @@ -170,7 +171,273 @@ def _create_org_owner(self, **kwargs): return org_owner -class TestMultitenantAdminMixin(TestOrganizationMixin): +class TestDisabledOrgMixin(TestOrganizationMixin): + """ + Shared helper for the disabled-organization admin and API test + mixins: creating the "superuser" / "org_admin" role users. + """ + + def _disabled_org_role_user(self, role, organization=None, **kwargs): + """ + Returns the user impersonating ``role``: + "superuser" is a superuser (``_get_admin()``/``_create_admin()`` + when ``kwargs`` is given, to avoid username collisions across + multiple calls in the same test); "org_admin" is a staff user in + the "Administrator" group who is (or, since ``organization`` is + disabled, *was*) its manager (``_create_administrator``, i.e. an + ``OrganizationUser`` with ``is_admin=True`` - this codebase's + existing meaning of "organization admin", not ``is_staff``). + """ + if role == "superuser": + return self._create_admin(**kwargs) if kwargs else self._get_admin() + if role == "org_admin": + if organization is None: + raise ValueError('role "org_admin" requires organization=') + return self._create_administrator(organizations=[organization], **kwargs) + raise ValueError(f"Unknown role: {role!r}") + + +class TestDisabledOrgAdminMixin(TestDisabledOrgMixin): + """ + Reusable assertions for ``MultitenantAdminMixin``'s + disabled-organization write protection (``has_change_permission`` / + ``_edit_form``), for downstream OpenWISP modules to exercise against + their own org-scoped ``ModelAdmin`` classes without re-implementing + the request plumbing. ``obj`` must already belong to a disabled + organization (or be reachable through ``multitenant_parent`` from + one) before any of these are called; creating/disabling the + organization is left to the caller. + + Note: once an organization is disabled, it drops out of every + user's ``organizations_managed`` (see ``organizations_dict``), so an + "org_admin" who managed it loses queryset visibility of its objects + entirely: admin views 404 rather than 403. This is why the two + roles have different default expectations below. + """ + + disabled_org_admin_default_expectations = { + "superuser": { + "view": {"status": 200}, + "change": {"status": 403, "unchanged": True}, + "delete": {"status": 200, "exists_after": False}, + }, + "org_admin": { + # the object is filtered out of get_queryset() before any + # permission check runs, so Django admin's own "doesn't + # exist" handling kicks in instead of DisabledOrgReadOnly's + # 403: a raw (unfollowed) GET redirects (302) to the admin + # index; a POST change/delete redirects the same way, which + # this mixin follows (matching how a successful change/ + # delete is asserted for superuser), landing on a 200 admin + # index page in both cases - "unchanged"/"exists_after" is + # what actually proves nothing happened, not the status code + "view": {"status": 302}, + "change": {"status": 200, "unchanged": True}, + "delete": {"status": 200, "exists_after": True}, + }, + } + + def _get_disabled_org_admin_urls(self, obj, admin_site="admin"): + """ + Derives the "view"/"change"/"delete" admin URLs for ``obj`` from + ``obj._meta.app_label``/``model_name``, following Django's + standard ``{admin_site}:{app_label}_{model_name}_{change,delete}`` + naming ("view" and "change" are the same URL, GET vs POST). + """ + meta = obj._meta + change_url = reverse( + f"{admin_site}:{meta.app_label}_{meta.model_name}_change", args=[obj.pk] + ) + delete_url = reverse( + f"{admin_site}:{meta.app_label}_{meta.model_name}_delete", args=[obj.pk] + ) + return {"view": change_url, "change": change_url, "delete": delete_url} + + def _test_disabled_org_admin_view(self, url, status=200): + """GETs ``url`` (the change view) and asserts the status code.""" + response = self.client.get(url) + self.assertEqual(response.status_code, status) + + def _test_disabled_org_admin_change( + self, + url, + change_data, + obj, + status=403, + unchanged=True, + unchanged_field="name", + ): + """ + POSTs ``change_data`` to ``url`` (``follow=True``) and asserts + ``status``. When ``unchanged`` is True, also asserts ``obj``'s + ``unchanged_field`` still equals its pre-POST value after + ``obj.refresh_from_db()`` - i.e. the blocked write did not + silently apply. + """ + if unchanged: + before = getattr(obj, unchanged_field) + response = self.client.post(url, change_data, follow=True) + self.assertEqual(response.status_code, status) + if unchanged: + obj.refresh_from_db() + self.assertEqual(getattr(obj, unchanged_field), before) + + def _test_disabled_org_admin_delete( + self, url, model, pk, status=200, exists_after=False + ): + """ + POSTs the delete confirmation and asserts ``status`` and whether + ``model.objects.filter(pk=pk).exists()`` equals ``exists_after``. + """ + response = self.client.post(url, {"post": "yes"}, follow=True) + self.assertEqual(response.status_code, status) + self.assertEqual(model.objects.filter(pk=pk).exists(), exists_after) + + def _test_disabled_org_admin_org_field_excludes_disabled( + self, + url, + disabled_org, + roles=("superuser",), + organization=None, + role_kwargs=None, + ): + """ + For each role, GETs ``url`` (an add or change view) and asserts + ``disabled_org`` is never offered as an ``organization`` choice. + Testing the "org_admin" role requires ``organization=`` to be a + *different*, still-active organization the role manages (an + org_admin whose only organization is the disabled one loses + ``has_add_permission`` entirely, so there would be no form to + inspect). + """ + role_kwargs = role_kwargs or {} + for role in roles: + with self.subTest(role=role): + user = self._disabled_org_role_user( + role, organization=organization, **role_kwargs.get(role, {}) + ) + self.client.force_login(user) + response = self.client.get(url) + self.assertNotContains(response, f"{disabled_org.name}") + self.client.logout() + + def _test_disabled_org_admin_crud( + self, + obj, + change_data, + roles=("org_admin", "superuser"), + operations=("view", "change", "delete"), + organization=None, + org_admin_expected=None, + superuser_expected=None, + unchanged_field="name", + ): + """ + Umbrella test: for each role in ``roles``, logs the role's user + in and runs each operation in ``operations`` against ``obj``, + asserting the outcome from ``disabled_org_admin_default_expectations`` + (per-role, shallow-overridden by ``org_admin_expected``/ + ``superuser_expected``). For anything this can't express (a + non-standard admin site/URL, extra ``_disabled_org_role_user`` + kwargs, skipping a role/operation entirely), call + ``_test_disabled_org_admin_view``/``_change``/``_delete`` + directly instead. + + The default role order is "org_admin" before "superuser" because + with the default expectations only the superuser's "delete" + actually removes ``obj`` (the org_admin's is a no-op, the object + never being in their queryset); a custom ``roles=`` combination + where a different role's action genuinely mutates or removes + ``obj`` should put that role last for the same reason. + + ``organization`` defaults to ``getattr(obj, "organization", None)``; + pass it explicitly for models reached through + ``multitenant_parent`` (it has no direct ``organization`` + attribute). + """ + organization = organization or getattr(obj, "organization", None) + urls = self._get_disabled_org_admin_urls(obj) + specs = { + "org_admin": { + **self.disabled_org_admin_default_expectations["org_admin"], + **(org_admin_expected or {}), + }, + "superuser": { + **self.disabled_org_admin_default_expectations["superuser"], + **(superuser_expected or {}), + }, + } + for role in roles: + user = self._disabled_org_role_user(role, organization=organization) + self.client.force_login(user) + for operation in operations: + spec = specs[role][operation] + with self.subTest(role=role, operation=operation): + if operation == "view": + self._test_disabled_org_admin_view(urls["view"], **spec) + elif operation == "change": + self._test_disabled_org_admin_change( + urls["change"], + change_data, + obj, + unchanged_field=unchanged_field, + **spec, + ) + elif operation == "delete": + self._test_disabled_org_admin_delete( + urls["delete"], type(obj), obj.pk, **spec + ) + else: + raise ValueError(f"Unknown operation: {operation!r}") + self.client.logout() + + def _test_disabled_org_admin_inline_readonly( + self, + model_admin, + disabled_obj, + active_obj=None, + inline_models=None, + user=None, + ): + """ + Generic proof that ``model_admin.get_inline_instances`` write- + protects every inline attached to ``disabled_obj`` (an instance + whose disabled organization is what triggers the guard - for + ``OrganizationAdmin`` that is the ``Organization`` itself; for a + downstream org-scoped ``ModelAdmin`` it is the parent object + belonging to the disabled org): add/change permission denied, + delete permission preserved. ``inline_models`` optionally + narrows the assertion to a subset of inline classes (matched via + ``isinstance``) when only some of a ``ModelAdmin``'s inlines are + expected to be write-protected. When ``active_obj`` is given, + also asserts its inlines stay fully writable, proving the guard + is specific to the disabled organization rather than blanket. + """ + request = RequestFactory().get("/") + request.user = user or self._get_admin() + + inlines = model_admin.get_inline_instances(request, disabled_obj) + if inline_models is not None: + inlines = [i for i in inlines if isinstance(i, inline_models)] + self.assertNotEqual(inlines, []) + for inline in inlines: + self.assertEqual(inline.has_add_permission(request, disabled_obj), False) + self.assertEqual(inline.has_change_permission(request, disabled_obj), False) + self.assertEqual(inline.has_delete_permission(request, disabled_obj), True) + + if active_obj is not None: + active_inlines = model_admin.get_inline_instances(request, active_obj) + if inline_models is not None: + active_inlines = [ + i for i in active_inlines if isinstance(i, inline_models) + ] + for inline in active_inlines: + self.assertEqual( + inline.has_change_permission(request, active_obj), True + ) + + +class TestMultitenantAdminMixin(TestDisabledOrgAdminMixin): def setUp(self): admin = self._create_admin(password="tester") admin.organizations_dict # force caching diff --git a/tests/testapp/__init__.py b/tests/testapp/__init__.py index 823fc7517..8b4d09742 100644 --- a/tests/testapp/__init__.py +++ b/tests/testapp/__init__.py @@ -38,3 +38,11 @@ def _create_tag(self, **kwargs): tag.full_clean() tag.save() return tag + + def _create_config(self, **kwargs): + options = dict(name="test-config") + options.update(kwargs) + config = self.config_model(**options) + config.full_clean() + config.save() + return config diff --git a/tests/testapp/admin.py b/tests/testapp/admin.py index 81a65e4e8..35afddfe5 100644 --- a/tests/testapp/admin.py +++ b/tests/testapp/admin.py @@ -14,12 +14,22 @@ class BaseAdmin(MultitenantAdminMixin, admin.ModelAdmin): pass +class BookInline(admin.TabularInline): + # Used to prove MultitenantAdminMixin.get_inline_instances write-protects + # inlines of a disabled organization even though this inline itself does + # not use MultitenantAdminMixin. + model = Book + fields = ["name", "author"] + extra = 0 + + class ShelfAdmin(BaseAdmin): list_display = ["name", "organization"] list_filter = [MultitenantOrgFilter] fields = ["name", "organization", "tags", "created", "modified"] search_fields = ["name"] multitenant_shared_relations = ["tags"] + inlines = [BookInline] class ShelfFilter(MultitenantRelatedOrgFilter): diff --git a/tests/testapp/tests/mixins.py b/tests/testapp/tests/mixins.py index 0d0459934..078c469d1 100644 --- a/tests/testapp/tests/mixins.py +++ b/tests/testapp/tests/mixins.py @@ -1,5 +1,4 @@ -from openwisp_users.tests.test_api import AuthenticationMixin -from openwisp_users.tests.test_api.utils import TestDisabledOrgApiMixin +from openwisp_users.tests.test_api import AuthenticationMixin, TestDisabledOrgApiMixin from openwisp_users.tests.utils import TestMultitenantAdminMixin from .. import CreateMixin diff --git a/tests/testapp/tests/test_multitenancy.py b/tests/testapp/tests/test_multitenancy.py index 988fdc71a..c502f1e3b 100644 --- a/tests/testapp/tests/test_multitenancy.py +++ b/tests/testapp/tests/test_multitenancy.py @@ -6,8 +6,8 @@ from openwisp_users.multitenancy import MultitenantAdminMixin -from ..admin import LibraryParentAdmin, ShelfAdmin -from ..models import Book, Library, Shelf +from ..admin import BookInline, LibraryParentAdmin, ShelfAdmin +from ..models import Book, Config, Library, Shelf from .mixins import TestMultitenancyMixin User = get_user_model() @@ -19,11 +19,14 @@ class ShelfDisabledOrgWriteAllowedAdmin(MultitenantAdminMixin, admin.ModelAdmin) # behaviour stays covered by the other tests in this file disabled_organization_write_protection = False fields = ["name", "organization"] + inlines = [BookInline] class TestMultitenancy(TestMultitenancyMixin, TestCase): book_model = Book shelf_model = Shelf + library_model = Library + config_model = Config def _create_multitenancy_test_env(self): org1 = self._create_org(name="org1") @@ -87,30 +90,71 @@ def test_book_shelf_fk_queryset(self): def test_shelf_disabled_organization_admin_guard(self): org = self._get_org() shelf = self._create_shelf(name="disable-guard-shelf", organization=org) - self.client.force_login(self._get_admin()) org.is_active = False org.save() - change_path = reverse("admin:testapp_shelf_change", args=[shelf.pk]) - delete_path = reverse("admin:testapp_shelf_delete", args=[shelf.pk]) - - with self.subTest("change blocked for superuser"): - # has_view_permission is untouched, so the read-only form - # still renders with a 200; has_change_permission is checked - # before form validation on POST, so it 403s regardless of - # what other field values are (or aren't) submitted - r = self.client.get(change_path) - self.assertEqual(r.status_code, 200) - r = self.client.post( - change_path, {"name": "renamed-shelf", "organization": str(org.pk)} - ) - self.assertEqual(r.status_code, 403) - shelf.refresh_from_db() - self.assertEqual(shelf.name, "disable-guard-shelf") + self._test_disabled_org_admin_crud( + shelf, + change_data={"name": "renamed-shelf", "organization": str(org.pk)}, + roles=("superuser",), + ) + + def test_disabled_org_admin_crud_org_admin_loses_access(self): + org = self._create_org(name="admin-mixin-org-oa") + shelf = self._create_shelf(name="admin-mixin-shelf-oa", organization=org) + org.is_active = False + org.save() + self._test_disabled_org_admin_crud( + shelf, + change_data={"name": "renamed", "organization": str(org.pk)}, + roles=("org_admin",), + ) + + def test_disabled_org_admin_crud_both_roles(self): + org = self._create_org(name="admin-mixin-org-both") + shelf = self._create_shelf(name="admin-mixin-shelf-both", organization=org) + org.is_active = False + org.save() + self._test_disabled_org_admin_crud( + shelf, + change_data={"name": "renamed", "organization": str(org.pk)}, + ) + + def test_disabled_org_admin_crud_operations_subset(self): + org = self._create_org(name="admin-mixin-org-subset") + shelf = self._create_shelf(name="admin-mixin-shelf-subset", organization=org) + org.is_active = False + org.save() + self._test_disabled_org_admin_crud( + shelf, + change_data={"name": "renamed", "organization": str(org.pk)}, + roles=("superuser",), + operations=("view",), + ) + self.assertEqual(self.shelf_model.objects.filter(pk=shelf.pk).exists(), True) - with self.subTest("delete still allowed"): - r = self.client.post(delete_path, {"post": "yes"}, follow=True) - self.assertEqual(r.status_code, 200) - self.assertEqual(self.shelf_model.objects.filter(pk=shelf.pk).count(), 0) + def test_shelf_disabled_org_admin_inline_readonly(self): + data = self._create_multitenancy_test_env() + shelf_admin = ShelfAdmin(Shelf, admin.site) + self._test_disabled_org_admin_inline_readonly( + shelf_admin, data["s3_inactive"], active_obj=data["s1"] + ) + + def test_shelf_disabled_org_admin_inline_readonly_opt_out(self): + # BookInline stays fully writable when the parent admin opts out of + # disabled_organization_write_protection + data = self._create_multitenancy_test_env() + shelf_admin = ShelfDisabledOrgWriteAllowedAdmin(Shelf, admin.site) + request = RequestFactory().get("/") + request.user = self._get_admin() + + inlines = shelf_admin.get_inline_instances(request, data["s3_inactive"]) + for inline in inlines: + self.assertEqual( + inline.has_add_permission(request, data["s3_inactive"]), True + ) + self.assertEqual( + inline.has_change_permission(request, data["s3_inactive"]), True + ) def test_multitenant_parent_disabled_organization_guard(self): data = self._create_multitenancy_test_env() @@ -139,6 +183,27 @@ def test_multitenant_parent_disabled_organization_guard(self): library_admin.has_delete_permission(request, disabled_library), True ) + def test_multitenant_parent_disabled_organization_guard_http(self): + org = self._create_org(name="admin-mixin-org-parent") + book = self._create_book(name="parent-book", organization=org) + library = self._create_library(name="parent-library", book=book) + org.is_active = False + org.save() + self._test_disabled_org_admin_crud( + library, + change_data={ + "name": "renamed", + "address": "", + "book": str(book.pk), + }, + organization=org, + org_admin_expected={ + "view": {"status": 403}, + "change": {"status": 403, "unchanged": True}, + "delete": {"status": 403, "exists_after": True}, + }, + ) + def test_add_permission_hidden_without_active_managed_org(self): disabled_org = self._create_org(name="operator-disabled-org", is_active=False) active_org = self._create_org(name="operator-active-org") @@ -185,3 +250,34 @@ def test_disabled_organization_write_protection_opt_out(self): form.save() shelf.refresh_from_db() self.assertEqual(shelf.organization_id, org.pk) + + def test_disabled_org_admin_crud_opt_out_override(self): + org = self._create_org(name="admin-mixin-org-optout") + config = self._create_config(name="optout-config", organization=org) + org.is_active = False + org.save() + self._test_disabled_org_admin_crud( + config, + change_data={ + "name": "renamed-config", + "organization": str(org.pk), + }, + roles=("superuser",), + operations=("change",), + superuser_expected={"change": {"status": 200, "unchanged": False}}, + ) + config.refresh_from_db() + self.assertEqual(config.name, "renamed-config") + + def test_disabled_org_admin_org_field_excludes_disabled(self): + active_org = self._create_org(name="admin-mixin-active-org") + disabled_org = self._create_org( + name="admin-mixin-disabled-org", is_active=False + ) + add_url = reverse("admin:testapp_shelf_add") + self._test_disabled_org_admin_org_field_excludes_disabled( + add_url, + disabled_org, + roles=("superuser", "org_admin"), + organization=active_org, + ) diff --git a/tests/testapp/tests/test_permission_classes.py b/tests/testapp/tests/test_permission_classes.py index b4bdbea0b..99f65b1ef 100644 --- a/tests/testapp/tests/test_permission_classes.py +++ b/tests/testapp/tests/test_permission_classes.py @@ -1,13 +1,18 @@ +import json + from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission -from django.test import TestCase +from django.test import RequestFactory, TestCase from django.urls import reverse +from rest_framework.test import APIRequestFactory from swapper import load_model from openwisp_users.api.permissions import DisabledOrgReadOnly from openwisp_users.api.throttling import AuthRateThrottle -from ..models import Template +from ..models import Shelf, Template +from ..serializers import BookManagerSerializer +from ..views import TemplateDetailView from .mixins import TestMultitenancyMixin User = get_user_model() @@ -365,28 +370,6 @@ def test_org_user_access_shared_object(self): }, ) - def _assert_disabled_org_blocks_write_but_allows_read( - self, template, auth, detail_url - ): - for method in ("put", "patch"): - with self.subTest(f"{method.upper()} blocked for superuser"): - response = getattr(self.client, method)( - detail_url, - data={"name": "renamed"}, - content_type="application/json", - **auth, - ) - self.assertEqual(response.status_code, 403) - self.assertEqual( - str(response.data["detail"]), str(DisabledOrgReadOnly.message) - ) - template.refresh_from_db() - self.assertEqual(template.name, "test-template") - - with self.subTest("GET allowed"): - response = self.client.get(detail_url, **auth) - self.assertEqual(response.status_code, 200) - def test_bare_protected_api_mixin_view_blocks_disabled_org_write(self): """ ProtectedTemplateDetailView declares no permission_classes of its @@ -401,9 +384,14 @@ def test_bare_protected_api_mixin_view_blocks_disabled_org_write(self): token = self._obtain_auth_token(username=admin) auth = dict(HTTP_AUTHORIZATION=f"Bearer {token}") detail_url = reverse("test_protected_template_detail", args=[template.pk]) - self._assert_disabled_org_blocks_write_but_allows_read( - template, auth, detail_url + self._test_disabled_org_api_update( + detail_url, + auth, + {"name": "renamed"}, + template, + error_contains=str(DisabledOrgReadOnly.message), ) + self._test_disabled_org_api_retrieve(detail_url, auth) def test_disabled_org_read_only_permission(self): org = self._get_org() @@ -417,9 +405,14 @@ def test_disabled_org_read_only_permission(self): allowed_url = reverse( "test_template_disabled_org_write_allowed_detail", args=[template.pk] ) - self._assert_disabled_org_blocks_write_but_allows_read( - template, auth, detail_url + self._test_disabled_org_api_update( + detail_url, + auth, + {"name": "renamed"}, + template, + error_contains=str(DisabledOrgReadOnly.message), ) + self._test_disabled_org_api_retrieve(detail_url, auth) with self.subTest("opt-out view allows write"): response = self.client.put( @@ -447,6 +440,158 @@ def test_disabled_org_read_only_permission(self): response = self.client.delete(detail_url, **auth) self.assertEqual(response.status_code, 204) + def test_disabled_org_api_crud_superuser_only(self): + org = self._create_org(name="api-mixin-org") + template = self._create_template(name="t-super", organization=org) + org.is_active = False + org.save() + self._test_disabled_org_api_crud( + template, + detail_url=reverse("test_template_detail", args=[template.pk]), + list_url=reverse("test_template_list"), + create_payload={"name": "t-super-new", "organization": str(org.pk)}, + update_payload={"name": "t-super-upd"}, + roles=("superuser",), + ) + + def test_disabled_org_api_crud_org_admin_loses_access(self): + org = self._create_org(name="api-mixin-org-oa") + template = self._create_template(name="t-oa", organization=org) + org.is_active = False + org.save() + self._test_disabled_org_api_crud( + template, + detail_url=reverse("test_template_detail", args=[template.pk]), + list_url=reverse("test_template_list"), + create_payload={"name": "t-oa-new", "organization": str(org.pk)}, + update_payload={"name": "t-oa-upd"}, + roles=("org_admin",), + org_admin_expected={ + "list": {"status": 200, "object_present": False}, + "retrieve": {"status": 404}, + "create": { + "status": 400, + "error_field": "organization", + "error_contains": "does not exist or is disabled", + }, + "update": {"status": 404, "unchanged": True}, + "delete": {"status": 404, "exists_after": True}, + }, + ) + + def test_disabled_org_api_crud_both_roles(self): + org = self._create_org(name="api-mixin-org-both") + template = self._create_template(name="t-both", organization=org) + org.is_active = False + org.save() + self._test_disabled_org_api_crud( + template, + detail_url=reverse("test_template_detail", args=[template.pk]), + list_url=reverse("test_template_list"), + create_payload={"name": "t-both-new", "organization": str(org.pk)}, + update_payload={"name": "t-both-upd"}, + org_admin_expected={ + "list": {"status": 200, "object_present": False}, + "retrieve": {"status": 404}, + "create": { + "status": 400, + "error_field": "organization", + "error_contains": "does not exist or is disabled", + }, + "update": {"status": 404, "unchanged": True}, + "delete": {"status": 404, "exists_after": True}, + }, + ) + + def test_disabled_org_api_crud_session_auth(self): + org = self._create_org(name="api-mixin-org-session") + template = self._create_template(name="t-sess", organization=org) + org.is_active = False + org.save() + self._test_disabled_org_api_crud( + template, + detail_url=reverse("test_protected_template_detail", args=[template.pk]), + roles=("superuser",), + operations=("retrieve", "update"), + update_payload={"name": "t-sess-upd"}, + auth_mechanism="session", + ) + + def test_disabled_org_api_crud_bare_protected_mixin(self): + org = self._create_org(name="api-mixin-org-bare") + template = self._create_template(name="t-bare", organization=org) + org.is_active = False + org.save() + self._test_disabled_org_api_crud( + template, + detail_url=reverse("test_protected_template_detail", args=[template.pk]), + roles=("superuser",), + operations=("retrieve", "update"), + update_payload={"name": "t-bare-upd"}, + ) + + def test_disabled_org_api_crud_operations_subset(self): + org = self._create_org(name="api-mixin-org-subset") + template = self._create_template(name="t-subset", organization=org) + org.is_active = False + org.save() + self._test_disabled_org_api_crud( + template, + detail_url=reverse("test_template_detail", args=[template.pk]), + roles=("superuser",), + operations=("retrieve",), + ) + self.assertEqual( + self.template_model.objects.filter(pk=template.pk).exists(), True + ) + + def test_disabled_org_api_crud_opt_out_override(self): + org = self._create_org(name="api-mixin-org-optout") + template = self._create_template(name="t-optout", organization=org) + detail_url = reverse("test_template_detail", args=[template.pk]) + allowed_url = reverse( + "test_template_disabled_org_write_allowed_detail", args=[template.pk] + ) + org.is_active = False + org.save() + self._test_disabled_org_api_crud( + template, + detail_url=detail_url, + roles=("superuser",), + operations=("retrieve", "update"), + update_payload={"name": "renamed"}, + ) + self._test_disabled_org_api_crud( + template, + detail_url=allowed_url, + roles=("superuser",), + operations=("update",), + update_payload={"name": "t-optout-up"}, + superuser_expected={"update": {"status": 200, "unchanged": False}}, + ) + template.refresh_from_db() + self.assertEqual(template.name, "t-optout-up") + + admin = self._get_admin() + token = self._obtain_auth_token(username=admin) + auth = dict(HTTP_AUTHORIZATION=f"Bearer {token}") + with self.subTest("shared object unaffected"): + shared_template = self._create_template( + name="shared-template", organization=None + ) + shared_url = reverse("test_template_detail", args=[shared_template.pk]) + response = self.client.put( + shared_url, + data={"name": "shared-renamed"}, + content_type="application/json", + **auth, + ) + self.assertEqual(response.status_code, 200) + + with self.subTest("DELETE allowed"): + response = self.client.delete(detail_url, **auth) + self.assertEqual(response.status_code, 204) + def test_organization_field_excludes_disabled_org(self): disabled_org = self._create_org(name="disabled-org", is_active=False) admin = self._get_admin() @@ -463,3 +608,43 @@ def test_organization_field_excludes_disabled_org(self): "does not exist or is disabled", str(response.data["organization"][0]) ) self.assertEqual(self.template_model.objects.filter(name="t1").count(), 0) + + def test_fk_field_excludes_disabled_org_for_superuser(self): + active_org = self._create_org(name="active-fk-org") + disabled_org = self._create_org(name="disabled-fk-org", is_active=False) + admin = self._get_admin() + shelf_active = Shelf(name="shelf-active", organization=active_org) + shelf_active.full_clean() + shelf_active.save() + shelf_disabled = Shelf(name="shelf-disabled", organization=disabled_org) + shelf_disabled.full_clean() + shelf_disabled.save() + request = APIRequestFactory().get("/") + request.user = admin + serializer = BookManagerSerializer(context={"request": request}) + shelf_pks = set( + serializer.fields["shelf"].queryset.values_list("pk", flat=True) + ) + self.assertIn(shelf_active.pk, shelf_pks) + self.assertNotIn(shelf_disabled.pk, shelf_pks) + + def test_disabled_org_read_only_denies_on_misconfigured_field(self): + class BrokenOrgFieldTemplateDetailView(TemplateDetailView): + # a misspelled organization_field must make the disabled-organization + # guard fail closed instead of silently granting write access + organization_field = "nonexistent_field" + + org = self._get_org() + template = self._create_template(organization=org) + admin = self._get_admin() + token = self._obtain_auth_token(username=admin) + request = RequestFactory().put( + "/", + data=json.dumps({"name": "renamed"}), + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + response = BrokenOrgFieldTemplateDetailView.as_view()(request, pk=template.pk) + self.assertEqual(response.status_code, 403) + template.refresh_from_db() + self.assertEqual(template.name, "test-template") From 8282f40c0b257cbc6be98372747667c8473e7d39 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Wed, 5 Aug 2026 22:13:16 +0530 Subject: [PATCH 09/34] [fix] Fixed failing tests --- openwisp_users/admin.py | 7 +- openwisp_users/base/models.py | 79 ++++++++++++++--------- openwisp_users/tests/test_admin.py | 3 +- openwisp_users/tests/test_api/__init__.py | 30 --------- openwisp_users/tests/test_api/test_api.py | 4 +- 5 files changed, 58 insertions(+), 65 deletions(-) diff --git a/openwisp_users/admin.py b/openwisp_users/admin.py index 830085348..573fbd83d 100644 --- a/openwisp_users/admin.py +++ b/openwisp_users/admin.py @@ -645,10 +645,15 @@ def get_inline_instances(self, request, obj=None): def has_change_permission(self, request, obj=None): """ - Allow only managers and superuser to change organization + Allow only managers and superuser to change organization. + Disabled organizations can still be changed so superusers can + re-enable them; ``get_readonly_fields`` ensures only + ``is_active`` is editable. """ if obj and not request.user.is_superuser and not request.user.is_manager(obj): return False + if obj and not obj.is_active: + return True return super().has_change_permission(request, obj) def get_readonly_fields(self, request, obj=None): diff --git a/openwisp_users/base/models.py b/openwisp_users/base/models.py index ea84e81ff..0cf2229bd 100644 --- a/openwisp_users/base/models.py +++ b/openwisp_users/base/models.py @@ -508,27 +508,39 @@ class Meta: abstract = True def clean(self): - if self.organization_id and not self.organization.is_active: - if self._state.adding: - raise ValidationError( - {"organization": _("Cannot add users to a disabled organization.")} - ) - # Only block real modifications: Django re-runs full_clean() on - # untouched inline rows, so a no-op save of a user who belongs to a - # disabled organization must not fail. - db_values = ( - self._meta.model.objects.filter(pk=self.pk) - .values("organization_id", "is_admin", "user_id") + original = None + if not self._state.adding: + original = ( + self.__class__.objects.select_related("organization") + .filter(pk=self.pk) .first() ) - if db_values is None or ( - db_values["organization_id"] != self.organization_id - or db_values["is_admin"] != self.is_admin - or db_values["user_id"] != self.user_id + + changed = ( + original is None + or original.organization_id != self.organization_id + or original.user_id != self.user_id + or original.is_admin != self.is_admin + ) + + if changed and self.organization_id is not None: + if not self.organization.is_active: + if self._state.adding: + raise ValidationError( + _("Cannot add users to a disabled organization.") + ) + raise ValidationError( + _("Memberships of a disabled organization cannot be modified.") + ) + if ( + original + and original.organization_id != self.organization_id + and not original.organization.is_active ): raise ValidationError( _("Memberships of a disabled organization cannot be modified.") ) + if ( not self._state.adding and self.user.is_owner(self.organization_id) @@ -559,23 +571,30 @@ class BaseOrganizationOwner(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) def clean(self): - if self.organization_id and not self.organization.is_active: - # Only block assigning or changing an owner: an untouched owner row - # is re-validated when its organization is disabled, and that must - # not prevent disabling the organization. - db_values = ( - self._meta.model.objects.filter(pk=self.pk) - .values("organization_id", "organization_user_id") + original = None + if self.pk: + original = ( + self.__class__.objects.select_related("organization") + .filter(pk=self.pk) .first() ) - if db_values is None or ( - db_values["organization_id"] != self.organization_id - or db_values["organization_user_id"] != self.organization_user_id - ): - raise ValidationError( - _("Cannot assign an owner to a disabled organization.") - ) - if self.organization_user.organization.pk != self.organization.pk: + changed = ( + original is None + or original.organization_id != self.organization_id + or original.organization_user_id != self.organization_user_id + ) + if changed and ( + not self.organization.is_active + or ( + original + and original.organization_id != self.organization_id + and not original.organization.is_active + ) + ): + raise ValidationError( + _("Cannot assign an owner to a disabled organization.") + ) + if self.organization_user.organization_id != self.organization_id: raise ValidationError( { "organization_user": _( diff --git a/openwisp_users/tests/test_admin.py b/openwisp_users/tests/test_admin.py index 786b89211..bf1c38658 100644 --- a/openwisp_users/tests/test_admin.py +++ b/openwisp_users/tests/test_admin.py @@ -1421,8 +1421,7 @@ def test_action_active_clears_expired_expiration_date(self): self.assertEqual(user.is_active, True) if expected_expiration_date is None: expected_message = ( - "Successfully activated 1 user and cleared 1 expiration" - " date." + "Successfully activated 1 user and cleared 1 expiration date." ) else: expected_message = "Successfully activated 1 user." diff --git a/openwisp_users/tests/test_api/__init__.py b/openwisp_users/tests/test_api/__init__.py index 4f125507f..ebc9b60a4 100644 --- a/openwisp_users/tests/test_api/__init__.py +++ b/openwisp_users/tests/test_api/__init__.py @@ -14,36 +14,6 @@ def _obtain_auth_token(self, username="operator", password="tester"): class TestDisabledOrgApiMixin(TestDisabledOrgMixin): - """ - Reusable assertions for the REST API's disabled-organization guard - (``DisabledOrgReadOnly`` plus the ``organization`` field filtering - performed by the ``FilterSerializerByOrganization`` subclasses), for - downstream OpenWISP modules to exercise against their own - org-scoped API views without re-implementing the request plumbing. - - Must be composed alongside ``AuthenticationMixin`` (for - ``_obtain_auth_token``), which every existing composite in this - codebase using this mixin already includes. - - Prerequisite (not enforced by this mixin): the serializer behind the - create payload must filter its ``organization`` field to active - organizations for the "create" assertion below to hold - one of - ``FilterSerializerByOrgManaged``/``Membership``/``Owned``, or an - equivalent explicit queryset, see - ``openwisp_users/api/mixins.py:FilterSerializerByOrganization``. - - Note: the default expectations assume the view uses - ``IsOrganizationManager`` in its ``permission_classes``. Once an - organization is disabled, it drops out of every user's - ``organizations_managed`` (see ``organizations_dict``), so an - "org_admin" who managed only that organization has an empty - ``organizations_managed`` list. ``IsOrganizationManager.has_permission()`` - blocks every request with 403 because the user no longer manages any - active organization. The superuser bypasses this check but is still - blocked by ``DisabledOrgReadOnly`` on update. This is why the two - roles have different default expectations below. - """ - disabled_org_api_default_expectations = { "superuser": { "list": {"status": 200, "object_present": True}, diff --git a/openwisp_users/tests/test_api/test_api.py b/openwisp_users/tests/test_api/test_api.py index 9316769b1..0ffafffb7 100644 --- a/openwisp_users/tests/test_api/test_api.py +++ b/openwisp_users/tests/test_api/test_api.py @@ -190,7 +190,7 @@ def test_create_organization_owner_api(self): data = {"owner": {"organization_user": org1_user1.pk}} # building the owner and saving it once (instead of objects.create() # followed by a redundant save()) removed two queries here - with self.assertNumQueries(16): + with self.assertNumQueries(17): r = self.client.patch(path, data, content_type="application/json") self.assertEqual(r.status_code, 200) self.assertEqual(r.data["owner"]["organization_user"], org1_user1.pk) @@ -292,7 +292,7 @@ def test_change_organizationowner_for_org(self): data = {"owner": {"organization_user": org1_user2.id}} # building the new owner and saving it once (instead of objects.create() # followed by a redundant save()) removed two queries here - with self.assertNumQueries(25): + with self.assertNumQueries(26): r = self.client.patch(path, data, content_type="application/json") org1.refresh_from_db() self.assertEqual(org1.owner.organization_user.id, org1_user2.id) From 6c7c9fed2219963607cca700d6537e0698848d67 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Thu, 6 Aug 2026 02:54:07 +0530 Subject: [PATCH 10/34] [fix] Fixed DisabledOrgReadonlyMixin --- openwisp_users/api/mixins.py | 6 ++++++ openwisp_users/tests/test_admin.py | 12 ++++++++++-- openwisp_users/tests/utils.py | 5 ++++- tests/testapp/tests/test_permission_classes.py | 9 +++++---- 4 files changed, 25 insertions(+), 7 deletions(-) diff --git a/openwisp_users/api/mixins.py b/openwisp_users/api/mixins.py index dab29f5e1..c3b36ece4 100644 --- a/openwisp_users/api/mixins.py +++ b/openwisp_users/api/mixins.py @@ -23,6 +23,8 @@ class OrgLookup: + select_related_organization = True + @property def org_field(self): return getattr(self, "organization_field", "organization") @@ -64,6 +66,8 @@ def queryset_organization_conditions(self): def get_queryset(self): qs = super().get_queryset() + if getattr(self, "select_related_organization", True): + qs = qs.select_related(self.org_field) if self.request.user.is_superuser: return qs return self.get_organization_queryset(qs) @@ -118,6 +122,8 @@ def assert_parent_exists(self): parent_queryset = self.get_parent_queryset() if not self.request.user.is_superuser: parent_queryset = self.get_organization_queryset(parent_queryset) + if getattr(self, "select_related_organization", True): + parent_queryset = parent_queryset.select_related(self.org_field) try: assert parent_queryset.exists() except (AssertionError, ValidationError): diff --git a/openwisp_users/tests/test_admin.py b/openwisp_users/tests/test_admin.py index bf1c38658..1a16a9df5 100644 --- a/openwisp_users/tests/test_admin.py +++ b/openwisp_users/tests/test_admin.py @@ -13,7 +13,7 @@ from django.core.exceptions import ValidationError from django.db import DEFAULT_DB_ALIAS from django.template.defaultfilters import date -from django.test import TestCase, override_settings +from django.test import RequestFactory, TestCase, override_settings from django.urls import reverse from django.utils.timezone import localdate, now, timedelta from freezegun import freeze_time @@ -100,6 +100,9 @@ def _get_api_key_inline_params(self, user, generate_token=False): params.update({"auth_token-0-generate_token": "on"}) return params + def _get_disabled_org_test_excluded_inline(self): + return [] + @property def add_user_inline_params(self): params = { @@ -2031,8 +2034,13 @@ def test_disabled_org_inlines_centrally_write_protected(self): org_admin = OrganizationAdmin(Organization, django_admin.site) active_org = self._create_org(name="active-inline-org") disabled_org = self._create_org(name="disabled-inline-org", is_active=False) + request = RequestFactory().get("/") + request.user = self._get_admin() + inlines = list(org_admin.get_inline_instances(request, disabled_org)) + for inline in self._get_disabled_org_test_excluded_inline(): + inlines.pop(inline, None) self._test_disabled_org_admin_inline_readonly( - org_admin, disabled_org, active_obj=active_org + org_admin, disabled_org, active_obj=active_org, inline_admins=inlines ) def test_organization_user_admin_disabled_organization(self): diff --git a/openwisp_users/tests/utils.py b/openwisp_users/tests/utils.py index ec4696cff..51536f6fd 100644 --- a/openwisp_users/tests/utils.py +++ b/openwisp_users/tests/utils.py @@ -397,6 +397,7 @@ def _test_disabled_org_admin_inline_readonly( disabled_obj, active_obj=None, inline_models=None, + inline_admins=None, user=None, ): """ @@ -416,7 +417,9 @@ def _test_disabled_org_admin_inline_readonly( request = RequestFactory().get("/") request.user = user or self._get_admin() - inlines = model_admin.get_inline_instances(request, disabled_obj) + inlines = inline_admins or model_admin.get_inline_instances( + request, disabled_obj + ) if inline_models is not None: inlines = [i for i in inlines if isinstance(i, inline_models)] self.assertNotEqual(inlines, []) diff --git a/tests/testapp/tests/test_permission_classes.py b/tests/testapp/tests/test_permission_classes.py index 99f65b1ef..8602f7bba 100644 --- a/tests/testapp/tests/test_permission_classes.py +++ b/tests/testapp/tests/test_permission_classes.py @@ -2,6 +2,7 @@ from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission +from django.core.exceptions import FieldError from django.test import RequestFactory, TestCase from django.urls import reverse from rest_framework.test import APIRequestFactory @@ -644,7 +645,7 @@ class BrokenOrgFieldTemplateDetailView(TemplateDetailView): content_type="application/json", HTTP_AUTHORIZATION=f"Bearer {token}", ) - response = BrokenOrgFieldTemplateDetailView.as_view()(request, pk=template.pk) - self.assertEqual(response.status_code, 403) - template.refresh_from_db() - self.assertEqual(template.name, "test-template") + with self.assertRaises(FieldError): + response = BrokenOrgFieldTemplateDetailView.as_view()( + request, pk=template.pk + ) From 9f2e2b310f9d5742ae4d73183f66dd832c67a862 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Thu, 6 Aug 2026 02:55:10 +0530 Subject: [PATCH 11/34] [fix] Fixed QA issues --- tests/testapp/tests/test_permission_classes.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/testapp/tests/test_permission_classes.py b/tests/testapp/tests/test_permission_classes.py index 8602f7bba..b18e684df 100644 --- a/tests/testapp/tests/test_permission_classes.py +++ b/tests/testapp/tests/test_permission_classes.py @@ -646,6 +646,4 @@ class BrokenOrgFieldTemplateDetailView(TemplateDetailView): HTTP_AUTHORIZATION=f"Bearer {token}", ) with self.assertRaises(FieldError): - response = BrokenOrgFieldTemplateDetailView.as_view()( - request, pk=template.pk - ) + BrokenOrgFieldTemplateDetailView.as_view()(request, pk=template.pk) From d40a45b51e7b8d4ac8f15a3b36fe9a747041e5ef Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Thu, 6 Aug 2026 03:48:16 +0530 Subject: [PATCH 12/34] [fix] Fixed inline exclusion --- openwisp_users/tests/test_admin.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openwisp_users/tests/test_admin.py b/openwisp_users/tests/test_admin.py index 1a16a9df5..710e18363 100644 --- a/openwisp_users/tests/test_admin.py +++ b/openwisp_users/tests/test_admin.py @@ -2037,8 +2037,10 @@ def test_disabled_org_inlines_centrally_write_protected(self): request = RequestFactory().get("/") request.user = self._get_admin() inlines = list(org_admin.get_inline_instances(request, disabled_org)) - for inline in self._get_disabled_org_test_excluded_inline(): - inlines.pop(inline, None) + for inline in inlines: + for excluded_inlines in self._get_disabled_org_test_excluded_inline(): + if isinstance(inline, excluded_inlines): + inlines.remove(inline) self._test_disabled_org_admin_inline_readonly( org_admin, disabled_org, active_obj=active_org, inline_admins=inlines ) From d429af68398558ba461fd1877a005934e0427415 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Thu, 6 Aug 2026 15:41:25 +0530 Subject: [PATCH 13/34] [fix] Made requested changes --- openwisp_users/api/mixins.py | 23 ++++++++++-- openwisp_users/multitenancy.py | 9 +++-- openwisp_users/tests/test_admin.py | 1 + openwisp_users/tests/test_api/test_api.py | 3 -- openwisp_users/tests/utils.py | 35 +++++++++---------- tests/testapp/tests/test_multitenancy.py | 3 ++ .../testapp/tests/test_permission_classes.py | 21 ++++++++--- 7 files changed, 65 insertions(+), 30 deletions(-) diff --git a/openwisp_users/api/mixins.py b/openwisp_users/api/mixins.py index c3b36ece4..3aad0830c 100644 --- a/openwisp_users/api/mixins.py +++ b/openwisp_users/api/mixins.py @@ -1,5 +1,5 @@ import swapper -from django.core.exceptions import ValidationError +from django.core.exceptions import FieldDoesNotExist, ValidationError from django.db.models import ForeignKey, ManyToManyField, Q from django.utils.translation import gettext_lazy as _ from django_filters import rest_framework as filters @@ -66,12 +66,31 @@ def queryset_organization_conditions(self): def get_queryset(self): qs = super().get_queryset() - if getattr(self, "select_related_organization", True): + if getattr( + self, "select_related_organization", True + ) and self._organization_relation_is_valid(qs.model): qs = qs.select_related(self.org_field) if self.request.user.is_superuser: return qs return self.get_organization_queryset(qs) + def _organization_relation_is_valid(self, model): + """ + ``select_related()`` does not validate its field argument until the + queryset is evaluated, so a misspelled ``organization_field`` would + otherwise crash later (e.g. inside ``get_object()``). + """ + for part in self.org_field.split("__"): + try: + field = model._meta.get_field(part) + except FieldDoesNotExist: + return False + related_model = getattr(field, "related_model", None) + if related_model is None: + return False + model = related_model + return True + def get_organization_queryset(self, qs): if self.request.user.is_anonymous: return diff --git a/openwisp_users/multitenancy.py b/openwisp_users/multitenancy.py index 00788f67a..9fbeccc1b 100644 --- a/openwisp_users/multitenancy.py +++ b/openwisp_users/multitenancy.py @@ -162,6 +162,12 @@ def _edit_form(self, request, form, obj=None): if keep_disabled_org_pk is not None: allowed |= Q(pk=keep_disabled_org_pk) org_field.queryset = org_field.queryset.filter(allowed) + active_or_shared = Q(organization__is_active=True) | Q(organization=None) + for field_name in self.multitenant_shared_relations: + if field_name not in fields: + continue + field = fields[field_name] + field.queryset = field.queryset.filter(active_or_shared) if user.is_superuser and org_field and not org_field.required: org_field.empty_label = SHARED_SYSTEMWIDE_LABEL elif not user.is_superuser: @@ -175,11 +181,8 @@ def _edit_form(self, request, form, obj=None): org_field.queryset = org_field.queryset.filter(managed) org_field.empty_label = None org_field.required = True - # other relations q = Q(organization__in=orgs_pk) | Q(organization=None) for field_name in self.multitenant_shared_relations: - # each relation may be readonly - # and not present in field list if field_name not in fields: continue field = fields[field_name] diff --git a/openwisp_users/tests/test_admin.py b/openwisp_users/tests/test_admin.py index 710e18363..1883134a5 100644 --- a/openwisp_users/tests/test_admin.py +++ b/openwisp_users/tests/test_admin.py @@ -2074,6 +2074,7 @@ def test_organization_user_admin_disabled_organization(self): }, follow=True, ) + self.assertEqual(response.status_code, 403) org_user.refresh_from_db() self.assertFalse(org_user.is_admin) diff --git a/openwisp_users/tests/test_api/test_api.py b/openwisp_users/tests/test_api/test_api.py index 0ffafffb7..448e005e0 100644 --- a/openwisp_users/tests/test_api/test_api.py +++ b/openwisp_users/tests/test_api/test_api.py @@ -835,9 +835,6 @@ def test_patch_resend_disabled_org_membership_preserves_it_api(self): r = self.client.patch(path, data, content_type="application/json") self.assertEqual(r.status_code, 400) self.assertIn("does not exist or is disabled", str(r.data)) - self.assertEqual( - OrganizationUser.objects.filter(user=user1, organization=org1).count(), 1 - ) def test_patch_user_org_membership_without_is_admin_preserves_it_api(self): user1 = self._create_user(username="user1", email="user1@email.com") diff --git a/openwisp_users/tests/utils.py b/openwisp_users/tests/utils.py index 51536f6fd..1685778f6 100644 --- a/openwisp_users/tests/utils.py +++ b/openwisp_users/tests/utils.py @@ -400,20 +400,6 @@ def _test_disabled_org_admin_inline_readonly( inline_admins=None, user=None, ): - """ - Generic proof that ``model_admin.get_inline_instances`` write- - protects every inline attached to ``disabled_obj`` (an instance - whose disabled organization is what triggers the guard - for - ``OrganizationAdmin`` that is the ``Organization`` itself; for a - downstream org-scoped ``ModelAdmin`` it is the parent object - belonging to the disabled org): add/change permission denied, - delete permission preserved. ``inline_models`` optionally - narrows the assertion to a subset of inline classes (matched via - ``isinstance``) when only some of a ``ModelAdmin``'s inlines are - expected to be write-protected. When ``active_obj`` is given, - also asserts its inlines stay fully writable, proving the guard - is specific to the disabled organization rather than blanket. - """ request = RequestFactory().get("/") request.user = user or self._get_admin() @@ -452,15 +438,24 @@ def _logout(self): self.client.logout() def _test_multitenant_admin( - self, url, visible, hidden, select_widget=False, administrator=False + self, + url, + visible, + hidden, + select_widget=False, + administrator=False, + superuser_hidden=None, ): """ reusable test function that ensures different users can see the right objects. an operator with limited permissions will not be able to see the elements contained in ``hidden``, while - a superuser can see everything. + a superuser can see everything, except the elements in + ``superuser_hidden`` (e.g. objects belonging to a disabled + organization, which relation pickers exclude for everyone). """ + superuser_hidden = superuser_hidden or [] if administrator: self._login(username="administrator", password="tester") else: @@ -492,12 +487,16 @@ def _f(el, select_widget=False): self._logout() self._login(username="admin", password="tester") response = self.client.get(url) - # ensure all elements are visible to superuser - all_elements = visible + hidden + # ensure all elements are visible to superuser, except superuser_hidden + all_elements = [el for el in visible + hidden if el not in superuser_hidden] for el in all_elements: self.assertContains( response, _f(el, select_widget), msg_prefix="[superuser contains]" ) + for el in superuser_hidden: + self.assertNotContains( + response, _f(el, select_widget), msg_prefix="[superuser not-contains]" + ) def _test_recoverlist_operator_403(self, app_label, model_label): self._login(username="operator", password="tester") diff --git a/tests/testapp/tests/test_multitenancy.py b/tests/testapp/tests/test_multitenancy.py index c502f1e3b..159dc7f8b 100644 --- a/tests/testapp/tests/test_multitenancy.py +++ b/tests/testapp/tests/test_multitenancy.py @@ -85,6 +85,9 @@ def test_book_shelf_fk_queryset(self): hidden=[data["s2"].name, data["s3_inactive"].name], select_widget=True, administrator=True, + # a disabled organization's shelf is excluded from the FK + # picker for everyone, superusers included + superuser_hidden=[data["s3_inactive"].name], ) def test_shelf_disabled_organization_admin_guard(self): diff --git a/tests/testapp/tests/test_permission_classes.py b/tests/testapp/tests/test_permission_classes.py index b18e684df..67ce34536 100644 --- a/tests/testapp/tests/test_permission_classes.py +++ b/tests/testapp/tests/test_permission_classes.py @@ -2,7 +2,6 @@ from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission -from django.core.exceptions import FieldError from django.test import RequestFactory, TestCase from django.urls import reverse from rest_framework.test import APIRequestFactory @@ -636,7 +635,7 @@ class BrokenOrgFieldTemplateDetailView(TemplateDetailView): organization_field = "nonexistent_field" org = self._get_org() - template = self._create_template(organization=org) + template = self._create_template(organization=org, name="original-name") admin = self._get_admin() token = self._obtain_auth_token(username=admin) request = RequestFactory().put( @@ -645,5 +644,19 @@ class BrokenOrgFieldTemplateDetailView(TemplateDetailView): content_type="application/json", HTTP_AUTHORIZATION=f"Bearer {token}", ) - with self.assertRaises(FieldError): - BrokenOrgFieldTemplateDetailView.as_view()(request, pk=template.pk) + response = BrokenOrgFieldTemplateDetailView.as_view()(request, pk=template.pk) + response.render() + self.assertEqual(response.status_code, 403) + template.refresh_from_db() + self.assertEqual(template.name, "original-name") + + def test_disabled_org_read_only_select_related_valid_field(self): + org = self._get_org() + self._create_template(organization=org) + admin = self._get_admin() + request = APIRequestFactory().get("/") + request.user = admin + view = TemplateDetailView() + view.request = request + queryset = view.get_queryset() + self.assertIn("organization", queryset.query.select_related) From aca37af2a042688e64d6291ba0a73dfc82478f6a Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Thu, 6 Aug 2026 15:41:25 +0530 Subject: [PATCH 14/34] [fix] Made requested changes --- openwisp_users/tests/test_api/test_api.py | 32 +++++++++++++++++++---- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/openwisp_users/tests/test_api/test_api.py b/openwisp_users/tests/test_api/test_api.py index 448e005e0..774823d48 100644 --- a/openwisp_users/tests/test_api/test_api.py +++ b/openwisp_users/tests/test_api/test_api.py @@ -182,6 +182,29 @@ def test_reenable_disabled_organization_via_put_api(self): org1.refresh_from_db() self.assertTrue(org1.is_active) + def test_reenable_disabled_organization_with_existing_owner_via_put_api(self): + user1 = self._create_user(username="user1", email="user1@email.com") + org1 = self._get_org() + org1_user1 = self._create_org_user(user=user1, organization=org1) + self._create_org_owner(organization_user=org1_user1, organization=org1) + org1.is_active = False + org1.save() + path = reverse("users:organization_detail", args=(org1.pk,)) + data = { + "name": org1.name, + "is_active": True, + "slug": org1.slug, + "description": org1.description, + "email": org1.email, + "url": org1.url, + "owner": {"organization_user": org1_user1.pk}, + } + response = self.client.put(path, data, content_type="application/json") + self.assertEqual(response.status_code, 200) + org1.refresh_from_db() + self.assertTrue(org1.is_active) + self.assertEqual(org1.owner.organization_user_id, org1_user1.pk) + def test_create_organization_owner_api(self): user1 = self._create_user(username="user1", email="user1@email.com") org1 = self._create_org(name="org1") @@ -820,17 +843,16 @@ def test_toggle_org_admin_disabled_org_api(self): OrganizationUser.objects.get(user=user1, organization=org1).is_admin ) - def test_patch_resend_disabled_org_membership_preserves_it_api(self): + def test_patch_resend_disabled_org_membership_deletes_it_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=False) org1.is_active = False org1.save() path = reverse("users:user_detail", args=(user1.pk,)) - # Re-sending an unchanged membership of a disabled organization must - # not silently delete it. The membership field only accepts active - # organizations, so the request is rejected (400) before the toggle - # delete path can run, and the membership is preserved. + # Re-sending an unchanged membership of a disabled organization is + # the only REST path to remove it: the "is_admin" toggle-delete + # contract still applies once the field resolves. data = {"organization_users": [{"is_admin": False, "organization": org1.pk}]} r = self.client.patch(path, data, content_type="application/json") self.assertEqual(r.status_code, 400) From a3ea34058e2b49668bf29a417f16bf1a28e2a0e6 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Fri, 7 Aug 2026 19:22:58 +0530 Subject: [PATCH 15/34] [fix] Removed AI slop --- docs/developer/admin-utils.rst | 30 +++--- .../developer/django-rest-framework-utils.rst | 41 ++++---- docs/user/basic-concepts.rst | 31 +++--- openwisp_users/admin.py | 40 +++----- openwisp_users/api/mixins.py | 80 +++++++-------- openwisp_users/api/permissions.py | 11 +-- openwisp_users/api/serializers.py | 57 +++++++---- openwisp_users/multitenancy.py | 47 +++------ openwisp_users/tests/test_admin.py | 17 ++-- openwisp_users/tests/test_api/test_api.py | 34 ++----- openwisp_users/tests/test_models.py | 14 +-- openwisp_users/tests/utils.py | 99 ++----------------- tests/testapp/admin.py | 9 +- tests/testapp/tests/test_multitenancy.py | 12 +-- .../testapp/tests/test_permission_classes.py | 8 +- tests/testapp/tests/test_views.py | 3 +- tests/testapp/views.py | 6 +- 17 files changed, 177 insertions(+), 362 deletions(-) diff --git a/docs/developer/admin-utils.rst b/docs/developer/admin-utils.rst index 5f6fe255e..a5712e8bc 100644 --- a/docs/developer/admin-utils.rst +++ b/docs/developer/admin-utils.rst @@ -36,12 +36,9 @@ Disabled Organization Write Protection ``MultitenantAdminMixin`` also blocks changes to any object belonging to a :ref:`disabled organization `, while still -allowing that object to be viewed and deleted. This applies to superusers -too: there is no per-user bypass (the only way to opt out is the -class-level attribute described below). For models whose organization is -reached through a parent (via ``multitenant_parent``), the mixin traverses -the parent to find the organization, so those child objects are protected -as well. +allowing it to be viewed and deleted. This also applies to superusers. For +models whose organization is reached through a parent, the mixin follows +``multitenant_parent`` to protect those objects too. This is controlled by the ``disabled_organization_write_protection`` class attribute, which defaults to ``True``. Set it to ``False`` on a specific @@ -57,18 +54,15 @@ attribute, which defaults to ``True``. Set it to ``False`` on a specific disabled_organization_write_protection = False # other attributes -The ``organization`` form field's queryset also excludes disabled -organizations for everyone, superusers included, so a disabled -organization can never be *selected* for a new object. The one exception -is an object that already belongs to a disabled organization on a -``ModelAdmin`` with the opt-out set: its own (disabled) organization stays -selectable in the field so the existing value can still be saved. - -The organization admin extends this protection to its **inlines**: when an -organization is disabled, every inline attached to the organization change -page becomes read-only, while deletion of the inline rows stays available. -An inline can opt out by setting ``disabled_organization_write_protection -= False`` on its class. +The ``organization`` form field excludes disabled organizations for +everyone, including superusers. An opted-out admin keeps the object's +current disabled organization selectable so it can be saved. + +The same protection applies to **inlines** attached to a disabled object. +The parent admin denies adding and changing inline rows but keeps deletion +available, even when an inline does not use the mixin. Set +``disabled_organization_write_protection = False`` on the parent admin or +on an individual inline to opt out. ``MultitenantOrgFilter`` ------------------------ diff --git a/docs/developer/django-rest-framework-utils.rst b/docs/developer/django-rest-framework-utils.rst index edf7928a3..87008b7e8 100644 --- a/docs/developer/django-rest-framework-utils.rst +++ b/docs/developer/django-rest-framework-utils.rst @@ -137,29 +137,25 @@ Standard users will not be able to view or list shared objects. **Full python path**: ``openwisp_users.api.permissions.DisabledOrgReadOnly``. -This object-level permission class blocks updating an object that belongs -to a :ref:`disabled organization `. Read (safe -methods) and ``DELETE`` remain allowed. +This object-level permission class blocks updates to objects belonging to +a :ref:`disabled organization `. ``GET``, +``HEAD``, ``OPTIONS`` and ``DELETE`` remain allowed. -The object's organization is located through the view's -``organization_field`` attribute (default ``"organization"``). If that -traversal fails, for example because ``organization_field`` is misspelled -or points to a relation that does not exist, the class **fails closed** -and denies the write. A view whose objects are genuinely not tied to an -organization must therefore opt out explicitly. +The object's organization is resolved through the view's +``organization_field`` attribute, which defaults to ``"organization"``. An +invalid relation path denies the write instead of failing open. Views that +are not organization-scoped must opt out explicitly. .. important:: ``DisabledOrgReadOnly`` guards **updates only**. It implements ``has_object_permission``, which DRF does not call on ``POST``, so it - does **not** block *creating* a new object for a disabled - organization. Create protection instead relies on the organization - field excluding disabled organizations: use one of the - ``FilterSerializerByOrganization`` mixins (or a related field backed - by ``Organization.active``) on the serializer. A plain - ``ModelSerializer`` whose organization field defaults to - ``Organization.objects`` will happily create objects for a disabled - organization even under ``ProtectedAPIMixin``. + does **not** block creation for a disabled organization. The + serializer must exclude disabled organizations from its + ``organization`` field, for example by using a + ``FilterSerializerByOrganization`` mixin or ``Organization.active``. A + plain ``ModelSerializer`` can still create an object for a disabled + organization. A view can opt out of this guard by setting ``allow_disabled_organization_writes = True``: @@ -173,7 +169,6 @@ A view can opt out of this guard by setting class SubnetView(RetrieveUpdateDestroyAPIView): permission_classes = (DisabledOrgReadOnly,) allow_disabled_organization_writes = True - # other attributes ``DisabledOrgReadOnly`` is already included in ``ProtectedAPIMixin``'s default ``permission_classes`` (see below), so views that use @@ -320,12 +315,10 @@ These serializers do not allow non-superusers to create shared objects. .. _multi_tenant_serializers_disabled_org: -The ``organization`` field's queryset also excludes :ref:`disabled -organizations `, for everyone, superusers -included, so a disabled organization can never be selected when creating -or updating an object. Submitting the primary key of a disabled -organization returns a validation error explaining that the organization -does not exist or is disabled. +These serializers also exclude :ref:`disabled organizations +` from the ``organization`` field for all +users, including superusers. Submitting a disabled organization's primary +key returns a validation error. Usage example: diff --git a/docs/user/basic-concepts.rst b/docs/user/basic-concepts.rst index fe4cd94e9..77e430436 100644 --- a/docs/user/basic-concepts.rst +++ b/docs/user/basic-concepts.rst @@ -154,27 +154,21 @@ instance of the platform. Disabling an Organization ------------------------- -Superusers and managers of the organization can disable it, by unchecking +Superusers and managers of the organization can disable it by unchecking its **Is active** flag on the "Change organization" page or via the REST API (subject to the usual permission requirements for editing an organization). -Disabling an organization does not delete anything: all of its data, -including users, memberships, and related objects, remains fully -**readable** and **deletable** for superusers. What changes is: +Disabling an organization does not delete its users, memberships, or +related objects. Superusers can still read and delete that data, but: - **No new object can be created for a disabled organization**, and - **existing objects belonging to it cannot be modified**, superusers - included. This applies to the organization's own record too: once - disabled, only its **Is active** flag can be changed (to re-enable it) - or its owner unassigned; everything else is locked until it is - re-enabled. -- Deleting objects, including the organization itself, is always allowed, - so cleanup is never blocked. -- The organization stops appearing in **organization selection widgets** - (e.g. when creating a new object), so it can no longer be picked for new - data. It still appears in admin **list filters**, so its existing data - remains easy to find for auditing purposes. + **existing objects belonging to it cannot be modified**. For the + organization itself, only **Is active** can be changed and its owner can + be unassigned while it is disabled. +- Deleting objects, including the organization itself, remains allowed. +- The organization is hidden from **organization selection widgets** but + remains available in admin **list filters**. - Re-enabling a disabled organization is allowed **only for superusers**. Once an organization is disabled, its managers lose access to it (a disabled organization is no longer part of the organizations they @@ -184,10 +178,9 @@ including users, memberships, and related objects, remains fully .. note:: - In the REST API, attempting to update an object belonging to a - disabled organization returns an HTTP 400 or 403 response with a clear - error message, instead of failing silently or being blocked without - explanation. + In the REST API, an update to an object in a disabled organization + returns HTTP 400 or 403, depending on the endpoint, with an error + message. .. note:: diff --git a/openwisp_users/admin.py b/openwisp_users/admin.py index 573fbd83d..a16669bff 100644 --- a/openwisp_users/admin.py +++ b/openwisp_users/admin.py @@ -115,10 +115,7 @@ def has_change_permission(self, request, obj=None): class OrganizationUserInlineFormSet(RequiredInlineFormSet): """ - Renders existing memberships of a disabled organization as read-only so - the row survives a no-op save (the disabled organization is not part of - the field queryset otherwise) and its select widget shows the disabled - organization instead of rendering empty. Deleting the row stays possible. + Keep disabled memberships valid on no-op saves while allowing deletion. """ def add_fields(self, form, index): @@ -148,16 +145,12 @@ class OrganizationUserInline(admin.StackedInline): autocomplete_fields = ("organization",) def get_queryset(self, request): - # OrganizationUserInlineFormSet.add_fields() reads - # instance.organization.is_active for every row; select_related - # folds that per-row query into this one. + # Avoid a query per inline row when checking the organization status. return super().get_queryset(request).select_related("organization") def get_formset(self, request, obj=None, **kwargs): """ - In form dropdowns, display only active organizations; - non-superusers additionally only see organizations - in which they are `is_admin`. + Limit membership choices to active organizations the user can manage. """ formset = super().get_formset(request, obj=obj, **kwargs) org_field = formset.form.base_fields["organization"] @@ -171,12 +164,8 @@ def get_formset(self, request, obj=None, **kwargs): def formfield_for_foreignkey(self, db_field, request, **kwargs): """ - Route the organization picker through the ``ow-auto-filter`` endpoint - so disabled organizations are excluded from the dropdown for everyone, - superusers included (the stock ``admin:autocomplete`` endpoint does not - filter them). Only replaces the widget when the field is actually an - autocomplete field, so that disabling ``autocomplete_fields`` keeps - rendering a plain select. + Use the filtered endpoint because the stock autocomplete includes + disabled organizations. """ if db_field.name == "organization" and db_field.name in ( self.get_autocomplete_fields(request) @@ -187,8 +176,7 @@ def formfield_for_foreignkey(self, db_field, request, **kwargs): return super().formfield_for_foreignkey(db_field, request, **kwargs) def has_add_permission(self, request, obj=None): - # an operator who manages no active organization cannot pick one, so - # the add row would be unusable: hide it + # Without an active managed organization, the add form cannot be used. if not request.user.is_superuser and not request.user.organizations_managed: return False return super().has_add_permission(request, obj) @@ -633,7 +621,7 @@ class OrganizationAdmin( def get_inline_instances(self, request, obj=None): """ - Remove OrganizationOwnerInline from the organization add form. + Owners require an existing organization, so omit this inline on add. """ inlines = super().get_inline_instances(request, obj).copy() if not obj: @@ -645,10 +633,8 @@ def get_inline_instances(self, request, obj=None): def has_change_permission(self, request, obj=None): """ - Allow only managers and superuser to change organization. - Disabled organizations can still be changed so superusers can - re-enable them; ``get_readonly_fields`` ensures only - ``is_active`` is editable. + Keep disabled organizations accessible so superusers can re-enable them; + read-only fields enforce the remaining restrictions. """ if obj and not request.user.is_superuser and not request.user.is_manager(obj): return False @@ -658,9 +644,8 @@ def has_change_permission(self, request, obj=None): def get_readonly_fields(self, request, obj=None): """ - A disabled organization can only be re-enabled: every other - field becomes readonly (owner unassignment is still possible - via the inline's delete action, which does not go through here). + Lock every field except ``is_active`` while disabled; owner removal uses + the inline delete action. """ fields = super().get_readonly_fields(request, obj) if obj and not obj.is_active: @@ -673,8 +658,7 @@ def get_readonly_fields(self, request, obj=None): return fields def get_prepopulated_fields(self, request, obj=None): - # prepopulated_fields cannot reference a field that is also - # readonly, which is the case for "slug" on a disabled organization + # Django rejects prepopulated fields that are read-only. if obj and not obj.is_active: return {} return super().get_prepopulated_fields(request, obj) diff --git a/openwisp_users/api/mixins.py b/openwisp_users/api/mixins.py index 3aad0830c..bc8cc3ab4 100644 --- a/openwisp_users/api/mixins.py +++ b/openwisp_users/api/mixins.py @@ -76,9 +76,8 @@ def get_queryset(self): def _organization_relation_is_valid(self, model): """ - ``select_related()`` does not validate its field argument until the - queryset is evaluated, so a misspelled ``organization_field`` would - otherwise crash later (e.g. inside ``get_object()``). + Check the relation before queryset evaluation can surface an invalid + ``select_related`` path. """ for part in self.org_field.split("__"): try: @@ -192,51 +191,44 @@ def _user_attr(self): raise NotImplementedError() def filter_fields(self): + """ + Restrict the querysets of writable relational fields so users can + only select active organizations they manage, and related objects + belonging to those organizations (including shared objects when + ``include_shared`` is set). + """ user = self.context["request"].user - # superuser can see everything, except disabled organizations - # The anonymouse use case exist so we don't run into errors with swagger - is_superuser_or_anonymous = user.is_superuser or user.is_anonymous - if not is_superuser_or_anonymous: - # non superusers can see only items of organizations - # they're related to + organization_filter = None + if not user.is_superuser and not user.is_anonymous: organization_filter = getattr(user, self._user_attr) - for field in self.fields: - if field == "organization" and not self.fields[field].read_only: - # queryset attribute will not be present if set to read_only - # disabled organizations are excluded for everyone, superusers - # included, since they can only be re-enabled, not written to - queryset = self.fields[field].queryset.filter(is_active=True) - self.fields[field].error_messages[ - "does_not_exist" - ] = DISABLED_ORGANIZATION_ERROR_MESSAGE - if not is_superuser_or_anonymous: - self.fields[field].allow_null = False - queryset = queryset.filter(pk__in=organization_filter) - self.fields[field].queryset = queryset - continue - if is_superuser_or_anonymous: - try: - self.fields[field].queryset = self.fields[field].queryset.filter( - Q(**{f"{self.org_field}__is_active": True}) - | Q(**{f"{self.org_field}__isnull": True}) - ) - except AttributeError: - pass - continue - conditions = Q( - **{ - self.organization_lookup: organization_filter, - f"{self.org_field}__is_active": True, - } - ) + for name, field in self.fields.items(): + if name == "organization" and not field.read_only: + self._filter_organization_field(field, organization_filter) + else: + self._filter_related_field(field, organization_filter) + + def _filter_organization_field(self, field, organization_filter): + # Keep disabled organizations out of writable relation fields. + queryset = field.queryset.filter(is_active=True) + field.error_messages["does_not_exist"] = DISABLED_ORGANIZATION_ERROR_MESSAGE + if organization_filter is not None: + field.allow_null = False + queryset = queryset.filter(pk__in=organization_filter) + field.queryset = queryset + + def _filter_related_field(self, field, organization_filter): + queryset = getattr(field, "queryset", None) + # Read-only and non-relational fields do not expose a queryset. + if queryset is None: + return + conditions = Q(**{f"{self.org_field}__is_active": True}) + if organization_filter is None: + conditions |= Q(**{f"{self.org_field}__isnull": True}) + else: + conditions &= Q(**{self.organization_lookup: organization_filter}) if self.include_shared: conditions |= Q(organization__isnull=True) - try: - self.fields[field].queryset = self.fields[field].queryset.filter( - conditions - ) - except AttributeError: - pass + field.queryset = queryset.filter(conditions) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/openwisp_users/api/permissions.py b/openwisp_users/api/permissions.py index a31700f12..2c080423a 100644 --- a/openwisp_users/api/permissions.py +++ b/openwisp_users/api/permissions.py @@ -97,9 +97,8 @@ def validate_membership(self, user, org): class DisabledOrgReadOnly(ObjectOrganizationMixin, BasePermission): """ - Blocks update of objects belonging to a disabled organization. - Read and delete remain allowed. Applies to superusers as well. - Views can opt out with `allow_disabled_organization_writes = True`. + Keep disabled-organization objects read-only while allowing reads and + deletion. Views can opt out with ``allow_disabled_organization_writes``. """ message = _( @@ -115,11 +114,7 @@ def has_object_permission(self, request, view, obj): try: organization = self.get_object_organization(view, obj) except AttributeError: - # A broken or misspelled organization_field must not fail open, - # so a misconfiguration cannot silently grant write access. Views - # that are genuinely not organization-bound opt out explicitly with - # allow_disabled_organization_writes = True instead of relying on - # this path. + # Do not fail open on a bad relation path; unrelated views opt out. return False return organization is None or organization.is_active diff --git a/openwisp_users/api/serializers.py b/openwisp_users/api/serializers.py index dbccaf208..0f0d15bb3 100644 --- a/openwisp_users/api/serializers.py +++ b/openwisp_users/api/serializers.py @@ -92,7 +92,17 @@ def get_queryset(self): queryset = OrganizationUser.objects.filter( Q(organization__in=user.organizations_managed) ) - return queryset.filter(organization__is_active=True).select_related() + allowed = Q(organization__is_active=True) + organization = getattr(self.root, "instance", None) + if organization is not None and not organization.is_active: + current_org_user_id = ( + OrganizationOwner.objects.filter(organization=organization) + .values_list("organization_user_id", flat=True) + .first() + ) + if current_org_user_id is not None: + allowed |= Q(pk=current_org_user_id) + return queryset.filter(allowed).select_related() class OrganizationOwnerSerializer(serializers.ModelSerializer): @@ -130,25 +140,29 @@ def validate(self, data): owner_present and owner_data.get("organization_user") is None ) reenabling = data.get("is_active") is True - # A key whose submitted value matches the value already stored is - # not a change, so a read-modify-write PUT that resends every - # field unchanged except is_active must not be rejected just - # because e.g. "name" is present in the payload. + # Compare values, not submitted keys, so unchanged PUT fields do + # not block re-enabling. changed_keys = { key for key in data if key != "owner" and getattr(self.instance, key) != data[key] } + owner_changed = False if owner_present: - changed_keys.add("owner") - # While disabled, only re-enabling (Is active) and/or unassigning - # the owner are allowed, and neither can be combined with any other - # change (editing a field or assigning an owner). This matches the - # admin interface, which locks every field except Is active, so the - # admin, the API and the docs tell the same story. + existing_owner = OrganizationOwner.objects.filter( + organization=self.instance + ).first() + existing_org_user = ( + existing_owner.organization_user if existing_owner else None + ) + # Unchanged owners must not block read-modify-write PUTs. + owner_changed = owner_data.get("organization_user") != existing_org_user + if owner_changed: + changed_keys.add("owner") + # Match the admin: while disabled, only re-enable or unassign the owner. allowed = ( changed_keys <= {"is_active", "owner"} - and (not owner_present or is_owner_unassignment) + and (not owner_changed or is_owner_unassignment) and (reenabling or is_owner_unassignment) ) if not allowed: @@ -246,10 +260,19 @@ class OrgUserCustomPrimarykeyRelatedField(serializers.PrimaryKeyRelatedField): def get_queryset(self): user = self.context["request"].user if user.is_superuser: - queryset = Organization.active.all() + queryset = Organization.objects.all() else: - queryset = Organization.active.filter(pk__in=user.organizations_managed) - return queryset + queryset = Organization.objects.filter(pk__in=user.organizations_managed) + allowed = Q(is_active=True) + # Existing disabled memberships must resolve so the deletion path works. + target_user = getattr(self.root, "instance", None) + if target_user is not None: + # Keep this as a subquery to avoid an extra round trip. + existing_disabled_orgs = OrganizationUser.objects.filter( + user=target_user, organization__is_active=False + ).values("organization_id") + allowed |= Q(pk__in=existing_disabled_orgs) + return queryset.filter(allowed) class OrganizationUserSerializer(serializers.ModelSerializer): @@ -339,9 +362,7 @@ def create(self, validated_data): password = validated_data.pop("password") email_verified = validated_data.pop("email_verified", False) - # Keep user and membership creation in a single transaction so a - # membership validation failure does not leave a half-created user - # behind while _full_clean_or_raise returns a 400. + # Roll back the user if membership validation fails. with transaction.atomic(): instance = self.instance or self.Meta.model(**validated_data) instance.set_password(password) diff --git a/openwisp_users/multitenancy.py b/openwisp_users/multitenancy.py index 9fbeccc1b..079076502 100644 --- a/openwisp_users/multitenancy.py +++ b/openwisp_users/multitenancy.py @@ -20,8 +20,7 @@ class MultitenantAdminMixin(object): multitenant_shared_relations = None multitenant_parent = None - # opt-out hook: set to False on subclasses that should allow writes - # to objects belonging to a disabled organization + # Set False on subclasses that allow writes to disabled-organization objects. disabled_organization_write_protection = True def __init__(self, *args, **kwargs): @@ -63,9 +62,8 @@ def get_queryset(self, request): def _get_object_organization(self, obj): """ - Returns the organization an object belongs to, traversing - ``multitenant_parent`` for models whose organization is reached - through a parent (e.g. a Book through its Shelf). + Resolve an object's organization, including through + ``multitenant_parent``. """ if self.model.__name__ == "Organization": return obj @@ -81,10 +79,7 @@ def _get_object_organization(self, obj): def has_change_permission(self, request, obj=None): """ - Objects belonging to a disabled organization stay readable and - deletable, but cannot be changed, regardless of the user being a - superuser. Subclasses can opt out with - ``disabled_organization_write_protection = False``. + Block changes to disabled organizations unless the admin opts out. """ if self.disabled_organization_write_protection and obj is not None: organization = self._get_object_organization(obj) @@ -94,9 +89,7 @@ def has_change_permission(self, request, obj=None): def get_inline_instances(self, request, obj=None): """ - When the edited object belongs to a disabled organization, make - every inline write-protected (no add, no change) while keeping - deletion available. + Disable add/change for inlines on objects from disabled organizations while keeping delete. """ inlines = super().get_inline_instances(request, obj) if obj is None or not self.disabled_organization_write_protection: @@ -112,42 +105,24 @@ def get_inline_instances(self, request, obj=None): def has_add_permission(self, request, *args, **kwargs): """ - Hide the Add button from admins who manage no active organization: - the organization dropdown would be empty and the form could never be - submitted. Does not apply to the user admin or to models without an - organization (directly or through ``multitenant_parent``). - - ``*args`` keeps this compatible with both ``ModelAdmin`` - (``request``) and ``InlineModelAdmin`` (``request, obj``), since this - mixin is used on inlines too. + Hide unusable add forms when no active organization is managed. """ if ( not request.user.is_superuser and self.model != User and not request.user.organizations_managed ): - # Any model with an organization field (directly, or reached - # through multitenant_parent) is blocked: _edit_form() makes the - # field required for non-superusers, so the form could not be - # submitted without an active organization to pick anyway. + # The form requires an organization, so it cannot work without one. if hasattr(self.model, "organization") or self.multitenant_parent: return False return super().has_add_permission(request, *args, **kwargs) def _edit_form(self, request, form, obj=None): """ - Modifies the form querysets as follows; - if current user is not superuser: - * show only relevant organizations - * show only relations associated to relevant organizations - or shared relations - * do not allow organization field to be empty (shared org) - else show everything - Organization choices always exclude disabled organizations, - superusers included, except an admin that opted out of write - protection (``disabled_organization_write_protection = False``) - keeps the edited object's own disabled organization selectable, - or the form could never be saved. + Filter form fields by organization and exclude disabled choices. + + An opted-out admin keeps the object's current disabled organization + selectable so the existing object can still be saved. """ fields = form.base_fields user = request.user diff --git a/openwisp_users/tests/test_admin.py b/openwisp_users/tests/test_admin.py index 1883134a5..c24149a02 100644 --- a/openwisp_users/tests/test_admin.py +++ b/openwisp_users/tests/test_admin.py @@ -1930,7 +1930,7 @@ def test_disable_organization_with_owner(self): params = { "name": org.name, "slug": org.slug, - # unchecking Is active must be allowed even when an owner exists + # An owner must not prevent disabling the organization. "is_active": "", "owner-TOTAL_FORMS": "1", "owner-INITIAL_FORMS": "1", @@ -2028,9 +2028,7 @@ def test_organization_owner_inline_disabled_organization(self): ) def test_disabled_org_inlines_centrally_write_protected(self): - # OrganizationAdmin write-protects every inline attached to it when the - # organization is disabled, so downstream inlines inherit the guard without - # re-implementing it. + # The parent admin protects inlines that do not use the mixin themselves. org_admin = OrganizationAdmin(Organization, django_admin.site) active_org = self._create_org(name="active-inline-org") disabled_org = self._create_org(name="disabled-inline-org", is_active=False) @@ -2060,9 +2058,7 @@ def test_organization_user_admin_disabled_organization(self): ) with self.subTest("Change blocked for superuser"): - # has_view_permission is untouched, so the read-only form - # still renders with a 200, POSTing a change is what must - # be rejected + # View access remains; only the write is rejected. response = self.client.get(change_path) self.assertEqual(response.status_code, 200) response = self.client.post( @@ -2111,8 +2107,7 @@ def test_user_admin_inline_disabled_organization(self): self.assertEqual(User.objects.filter(username="disableduserinline").count(), 0) def test_user_inline_org_picker_excludes_disabled(self): - # the membership organization picker must go through the ow-auto-filter - # endpoint with exclude_disabled=true, so disabled orgs are not offered + # The autocomplete endpoint must exclude disabled organizations. admin = self._get_admin() self.client.force_login(admin) response = self.client.get(reverse(f"admin:{self.app_label}_user_add")) @@ -2157,7 +2152,7 @@ def _base_params(): with self.subTest("editing an unrelated field saves without error"): params = _base_params() params["first_name"] = "Changed" - # the disabled inline fields are not submitted by a real browser + # Disabled fields are omitted from browser submissions. params.update( { f"{inline_prefix}-TOTAL_FORMS": 1, @@ -2172,7 +2167,7 @@ def _base_params(): self.assertNotContains(response, "Select a valid choice") user.refresh_from_db() self.assertEqual(user.first_name, "Changed") - # the membership must still exist and be unchanged + # Preserve the disabled membership when another user field changes. org_user.refresh_from_db() self.assertEqual(org_user.organization_id, org.pk) self.assertEqual(org_user.is_admin, True) diff --git a/openwisp_users/tests/test_api/test_api.py b/openwisp_users/tests/test_api/test_api.py index 774823d48..d09e6eb40 100644 --- a/openwisp_users/tests/test_api/test_api.py +++ b/openwisp_users/tests/test_api/test_api.py @@ -143,17 +143,10 @@ def test_patch_disabled_organization_reenable_api(self): self.assertTrue(org1.is_active) def test_reenable_disabled_organization_with_field_edit_api(self): - """ - Re-enabling (is_active) and editing another field in the same - request is rejected: re-enabling and editing must be two separate - requests, matching the admin and the docs. - """ org1 = self._get_org() org1.is_active = False org1.save() path = reverse("users:organization_detail", args=(org1.pk,)) - # re-enabling and editing another field in one request is rejected, - # the two-step matches the admin and the docs data = {"is_active": True, "name": "renamed while disabled"} r = self.client.patch(path, data, content_type="application/json") self.assertEqual(r.status_code, 400) @@ -166,9 +159,7 @@ def test_reenable_disabled_organization_via_put_api(self): org1.is_active = False org1.save() path = reverse("users:organization_detail", args=(org1.pk,)) - # a PUT always resends every required field, "name" included; since - # its value is unchanged it must not count as an edit and block the - # re-enable, the way a read-modify-write client would use PUT + # PUT resends unchanged fields, so they must not block re-enabling. data = { "name": org1.name, "is_active": True, @@ -211,8 +202,6 @@ def test_create_organization_owner_api(self): org1_user1 = self._create_org_user(user=user1, organization=org1) path = reverse("users:organization_detail", args=(org1.pk,)) data = {"owner": {"organization_user": org1_user1.pk}} - # building the owner and saving it once (instead of objects.create() - # followed by a redundant save()) removed two queries here with self.assertNumQueries(17): r = self.client.patch(path, data, content_type="application/json") self.assertEqual(r.status_code, 200) @@ -313,8 +302,6 @@ def test_change_organizationowner_for_org(self): self.assertEqual(org1.owner.organization_user.id, org1_user1.id) path = reverse("users:organization_detail", args=(org1.pk,)) data = {"owner": {"organization_user": org1_user2.id}} - # building the new owner and saving it once (instead of objects.create() - # followed by a redundant save()) removed two queries here with self.assertNumQueries(26): r = self.client.patch(path, data, content_type="application/json") org1.refresh_from_db() @@ -850,9 +837,7 @@ def test_patch_resend_disabled_org_membership_deletes_it_api(self): org1.is_active = False org1.save() path = reverse("users:user_detail", args=(user1.pk,)) - # Re-sending an unchanged membership of a disabled organization is - # the only REST path to remove it: the "is_admin" toggle-delete - # contract still applies once the field resolves. + # Resending an unchanged membership exercises the toggle-delete contract. data = {"organization_users": [{"is_admin": False, "organization": org1.pk}]} r = self.client.patch(path, data, content_type="application/json") self.assertEqual(r.status_code, 400) @@ -863,8 +848,7 @@ def test_patch_user_org_membership_without_is_admin_preserves_it_api(self): org1 = self._create_org(name="org1") self._create_org_user(user=user1, organization=org1, is_admin=True) path = reverse("users:user_detail", args=(user1.pk,)) - # omitting is_admin must leave the membership unchanged instead of - # raising a KeyError (500) or deleting it implicitly + # Omitting ``is_admin`` must preserve the membership. data = {"organization_users": [{"organization": org1.pk}]} r = self.client.patch(path, data, content_type="application/json") self.assertEqual(r.status_code, 200) @@ -1028,13 +1012,8 @@ def setUp(self): self.client.force_login(self._get_admin()) def test_create_user_organization_users_disabled_org_api(self): - # A membership validation failure after the user row is written must - # roll the user back instead of leaving a half-created account behind. - # The membership field only accepts active organizations, so field - # validation would normally reject a disabled org before the user is - # ever created; patch its queryset to let field validation pass, so the - # model's clean() is what fails, inside the atomic block, after the - # user row has been written. This exercises the transaction rollback. + # Bypass field validation so model validation fails after the user is + # saved, proving the transaction rolls both rows back. path = reverse("users:user_list") org1 = self._create_org(name="disabled-org", is_active=False) data = { @@ -1044,8 +1023,7 @@ def test_create_user_organization_users_disabled_org_api(self): "organization_users": {"is_admin": False, "organization": org1.pk}, } - # a real function (not a MagicMock) so DRF can still introspect - # get_queryset via its __func__ attribute + # Keep a real function so DRF can inspect ``__func__``. def get_all_orgs(self): return Organization.objects.all() diff --git a/openwisp_users/tests/test_models.py b/openwisp_users/tests/test_models.py index f4a5a2a31..faf1f79b2 100644 --- a/openwisp_users/tests/test_models.py +++ b/openwisp_users/tests/test_models.py @@ -441,7 +441,6 @@ def test_organization_user_clean_disabled_organization(self): "Memberships of a disabled organization cannot be modified.", ): org_user.full_clean() - # deleting the row must still work pk = org_user.pk org_user.delete() self.assertEqual(OrganizationUser.objects.filter(pk=pk).count(), 0) @@ -480,8 +479,7 @@ def test_organization_user_clean_disabled_organization(self): org.is_active = False org.save() org_user.refresh_from_db() - # a no-op full_clean() (nothing changed) must not raise, otherwise - # Django admin cannot save a user who has a disabled-org membership + # Existing disabled memberships must remain valid on no-op saves. org_user.full_clean() with self.subTest("reassign the user of a disabled organization membership"): @@ -552,18 +550,14 @@ def test_organization_owner_clean_disabled_organization(self): org.is_active = False org.save() org_owner.refresh_from_db() - # disabling an organization that already has an owner must not fail - # when the untouched owner row is re-validated + # Revalidating an unchanged owner must remain valid. org_owner.full_clean() def test_create_organization_owner_signal_defends_bypassed_validation(self): - # Django never runs full_clean() automatically on save(), so this - # models a write that bypasses validation (migration, fixture, - # shell); the signal must not crash. + # ``save()`` skips ``full_clean()``, so signals must tolerate legacy writes. org = self._create_org(name="disabled-org-signal", is_active=False) user = self._create_user() - # Bypassing validation by creating an OrganizationUser directly, - # without calling full_clean() to test signal receiver. + # Bypass ``full_clean()`` to exercise the signal directly. OrganizationUser.objects.create(organization=org, user=user, is_admin=True) self.assertEqual( OrganizationOwner.objects.filter(organization=org).exists(), False diff --git a/openwisp_users/tests/utils.py b/openwisp_users/tests/utils.py index 1685778f6..19393df45 100644 --- a/openwisp_users/tests/utils.py +++ b/openwisp_users/tests/utils.py @@ -172,22 +172,10 @@ def _create_org_owner(self, **kwargs): class TestDisabledOrgMixin(TestOrganizationMixin): - """ - Shared helper for the disabled-organization admin and API test - mixins: creating the "superuser" / "org_admin" role users. - """ + """Shared setup for disabled-organization admin and API tests.""" def _disabled_org_role_user(self, role, organization=None, **kwargs): - """ - Returns the user impersonating ``role``: - "superuser" is a superuser (``_get_admin()``/``_create_admin()`` - when ``kwargs`` is given, to avoid username collisions across - multiple calls in the same test); "org_admin" is a staff user in - the "Administrator" group who is (or, since ``organization`` is - disabled, *was*) its manager (``_create_administrator``, i.e. an - ``OrganizationUser`` with ``is_admin=True`` - this codebase's - existing meaning of "organization admin", not ``is_staff``). - """ + """Use a former organization manager to exercise disabled-org access.""" if role == "superuser": return self._create_admin(**kwargs) if kwargs else self._get_admin() if role == "org_admin": @@ -198,21 +186,10 @@ def _disabled_org_role_user(self, role, organization=None, **kwargs): class TestDisabledOrgAdminMixin(TestDisabledOrgMixin): - """ - Reusable assertions for ``MultitenantAdminMixin``'s - disabled-organization write protection (``has_change_permission`` / - ``_edit_form``), for downstream OpenWISP modules to exercise against - their own org-scoped ``ModelAdmin`` classes without re-implementing - the request plumbing. ``obj`` must already belong to a disabled - organization (or be reachable through ``multitenant_parent`` from - one) before any of these are called; creating/disabling the - organization is left to the caller. - - Note: once an organization is disabled, it drops out of every - user's ``organizations_managed`` (see ``organizations_dict``), so an - "org_admin" who managed it loses queryset visibility of its objects - entirely: admin views 404 rather than 403. This is why the two - roles have different default expectations below. + """Reusable assertions for disabled-organization admin behavior. + + Callers must provide an object that already belongs to a disabled + organization. """ disabled_org_admin_default_expectations = { @@ -222,15 +199,8 @@ class TestDisabledOrgAdminMixin(TestDisabledOrgMixin): "delete": {"status": 200, "exists_after": False}, }, "org_admin": { - # the object is filtered out of get_queryset() before any - # permission check runs, so Django admin's own "doesn't - # exist" handling kicks in instead of DisabledOrgReadOnly's - # 403: a raw (unfollowed) GET redirects (302) to the admin - # index; a POST change/delete redirects the same way, which - # this mixin follows (matching how a successful change/ - # delete is asserted for superuser), landing on a 200 admin - # index page in both cases - "unchanged"/"exists_after" is - # what actually proves nothing happened, not the status code + # The disabled object is outside the manager's queryset, so the + # admin redirects instead of reaching the permission check. "view": {"status": 302}, "change": {"status": 200, "unchanged": True}, "delete": {"status": 200, "exists_after": True}, @@ -238,12 +208,6 @@ class TestDisabledOrgAdminMixin(TestDisabledOrgMixin): } def _get_disabled_org_admin_urls(self, obj, admin_site="admin"): - """ - Derives the "view"/"change"/"delete" admin URLs for ``obj`` from - ``obj._meta.app_label``/``model_name``, following Django's - standard ``{admin_site}:{app_label}_{model_name}_{change,delete}`` - naming ("view" and "change" are the same URL, GET vs POST). - """ meta = obj._meta change_url = reverse( f"{admin_site}:{meta.app_label}_{meta.model_name}_change", args=[obj.pk] @@ -254,7 +218,6 @@ def _get_disabled_org_admin_urls(self, obj, admin_site="admin"): return {"view": change_url, "change": change_url, "delete": delete_url} def _test_disabled_org_admin_view(self, url, status=200): - """GETs ``url`` (the change view) and asserts the status code.""" response = self.client.get(url) self.assertEqual(response.status_code, status) @@ -267,13 +230,6 @@ def _test_disabled_org_admin_change( unchanged=True, unchanged_field="name", ): - """ - POSTs ``change_data`` to ``url`` (``follow=True``) and asserts - ``status``. When ``unchanged`` is True, also asserts ``obj``'s - ``unchanged_field`` still equals its pre-POST value after - ``obj.refresh_from_db()`` - i.e. the blocked write did not - silently apply. - """ if unchanged: before = getattr(obj, unchanged_field) response = self.client.post(url, change_data, follow=True) @@ -285,10 +241,6 @@ def _test_disabled_org_admin_change( def _test_disabled_org_admin_delete( self, url, model, pk, status=200, exists_after=False ): - """ - POSTs the delete confirmation and asserts ``status`` and whether - ``model.objects.filter(pk=pk).exists()`` equals ``exists_after``. - """ response = self.client.post(url, {"post": "yes"}, follow=True) self.assertEqual(response.status_code, status) self.assertEqual(model.objects.filter(pk=pk).exists(), exists_after) @@ -301,15 +253,6 @@ def _test_disabled_org_admin_org_field_excludes_disabled( organization=None, role_kwargs=None, ): - """ - For each role, GETs ``url`` (an add or change view) and asserts - ``disabled_org`` is never offered as an ``organization`` choice. - Testing the "org_admin" role requires ``organization=`` to be a - *different*, still-active organization the role manages (an - org_admin whose only organization is the disabled one loses - ``has_add_permission`` entirely, so there would be no form to - inspect). - """ role_kwargs = role_kwargs or {} for role in roles: with self.subTest(role=role): @@ -332,29 +275,7 @@ def _test_disabled_org_admin_crud( superuser_expected=None, unchanged_field="name", ): - """ - Umbrella test: for each role in ``roles``, logs the role's user - in and runs each operation in ``operations`` against ``obj``, - asserting the outcome from ``disabled_org_admin_default_expectations`` - (per-role, shallow-overridden by ``org_admin_expected``/ - ``superuser_expected``). For anything this can't express (a - non-standard admin site/URL, extra ``_disabled_org_role_user`` - kwargs, skipping a role/operation entirely), call - ``_test_disabled_org_admin_view``/``_change``/``_delete`` - directly instead. - - The default role order is "org_admin" before "superuser" because - with the default expectations only the superuser's "delete" - actually removes ``obj`` (the org_admin's is a no-op, the object - never being in their queryset); a custom ``roles=`` combination - where a different role's action genuinely mutates or removes - ``obj`` should put that role last for the same reason. - - ``organization`` defaults to ``getattr(obj, "organization", None)``; - pass it explicitly for models reached through - ``multitenant_parent`` (it has no direct ``organization`` - attribute). - """ + """Run shared checks for direct or parent-linked organizations.""" organization = organization or getattr(obj, "organization", None) urls = self._get_disabled_org_admin_urls(obj) specs = { @@ -487,7 +408,7 @@ def _f(el, select_widget=False): self._logout() self._login(username="admin", password="tester") response = self.client.get(url) - # ensure all elements are visible to superuser, except superuser_hidden + # Relation pickers still hide disabled values from superusers. all_elements = [el for el in visible + hidden if el not in superuser_hidden] for el in all_elements: self.assertContains( diff --git a/tests/testapp/admin.py b/tests/testapp/admin.py index 35afddfe5..0b776c3cf 100644 --- a/tests/testapp/admin.py +++ b/tests/testapp/admin.py @@ -15,9 +15,7 @@ class BaseAdmin(MultitenantAdminMixin, admin.ModelAdmin): class BookInline(admin.TabularInline): - # Used to prove MultitenantAdminMixin.get_inline_instances write-protects - # inlines of a disabled organization even though this inline itself does - # not use MultitenantAdminMixin. + # Verify the parent mixin protects inlines that do not use it. model = Book fields = ["name", "author"] extra = 0 @@ -79,13 +77,12 @@ class TagAdmin(BaseAdmin): class LibraryParentAdmin(MultitenantAdminMixin, admin.ModelAdmin): - # Library has no organization field; it is reached through its Book parent + # Resolve the organization through Book for parent traversal coverage. multitenant_parent = "book" class ConfigAdmin(BaseAdmin): - # Dedicated admin used to test the disabled_organization_write_protection - # opt-out through the admin URLs + # Exercise the write-protection opt-out through the admin endpoints. disabled_organization_write_protection = False fields = ["name", "organization", "template"] diff --git a/tests/testapp/tests/test_multitenancy.py b/tests/testapp/tests/test_multitenancy.py index 159dc7f8b..321900ed9 100644 --- a/tests/testapp/tests/test_multitenancy.py +++ b/tests/testapp/tests/test_multitenancy.py @@ -14,9 +14,7 @@ class ShelfDisabledOrgWriteAllowedAdmin(MultitenantAdminMixin, admin.ModelAdmin): - # dedicated admin used only to test the disabled_organization_write_protection - # opt-out; kept separate from ShelfAdmin so its default (protected) - # behaviour stays covered by the other tests in this file + # Test the opt-out separately so ShelfAdmin's default remains covered. disabled_organization_write_protection = False fields = ["name", "organization"] inlines = [BookInline] @@ -85,8 +83,7 @@ def test_book_shelf_fk_queryset(self): hidden=[data["s2"].name, data["s3_inactive"].name], select_widget=True, administrator=True, - # a disabled organization's shelf is excluded from the FK - # picker for everyone, superusers included + # Keep disabled organizations hidden even for superusers. superuser_hidden=[data["s3_inactive"].name], ) @@ -143,8 +140,7 @@ def test_shelf_disabled_org_admin_inline_readonly(self): ) def test_shelf_disabled_org_admin_inline_readonly_opt_out(self): - # BookInline stays fully writable when the parent admin opts out of - # disabled_organization_write_protection + # The parent opt-out keeps BookInline writable. data = self._create_multitenancy_test_env() shelf_admin = ShelfDisabledOrgWriteAllowedAdmin(Shelf, admin.site) request = RequestFactory().get("/") @@ -175,8 +171,6 @@ def test_multitenant_parent_disabled_organization_guard(self): ) with self.subTest("change blocked for object of disabled parent org"): - # applies to superusers too: the object is reached through - # multitenant_parent, so the guard must traverse it self.assertEqual( library_admin.has_change_permission(request, disabled_library), False ) diff --git a/tests/testapp/tests/test_permission_classes.py b/tests/testapp/tests/test_permission_classes.py index 67ce34536..1d8226c5d 100644 --- a/tests/testapp/tests/test_permission_classes.py +++ b/tests/testapp/tests/test_permission_classes.py @@ -371,11 +371,6 @@ def test_org_user_access_shared_object(self): ) def test_bare_protected_api_mixin_view_blocks_disabled_org_write(self): - """ - ProtectedTemplateDetailView declares no permission_classes of its - own; it relies entirely on inheriting ProtectedAPIMixin. Proves the - guard applies without a downstream app having to re-declare it. - """ org = self._get_org() template = self._create_template(organization=org) org.is_active = False @@ -630,8 +625,7 @@ def test_fk_field_excludes_disabled_org_for_superuser(self): def test_disabled_org_read_only_denies_on_misconfigured_field(self): class BrokenOrgFieldTemplateDetailView(TemplateDetailView): - # a misspelled organization_field must make the disabled-organization - # guard fail closed instead of silently granting write access + # A bad relation path must fail closed to avoid granting write access. organization_field = "nonexistent_field" org = self._get_org() diff --git a/tests/testapp/tests/test_views.py b/tests/testapp/tests/test_views.py index 7859ad91b..87820d9cc 100644 --- a/tests/testapp/tests/test_views.py +++ b/tests/testapp/tests/test_views.py @@ -84,8 +84,7 @@ def test_autocomplete_view_excludes_disabled_organization(self): self.assertIn(str(org2.pk), ids) with self.subTest("exclude_disabled=false keeps disabled org"): - # only the literal "true" enables the filter, otherwise a value - # like "false" would wrongly exclude disabled organizations + # Treat only "true" as opt-in; "false" must leave results unchanged. response = self.client.get(path + "&exclude_disabled=false") ids = [option["id"] for option in response.json()["results"]] self.assertIn(str(org1.pk), ids) diff --git a/tests/testapp/views.py b/tests/testapp/views.py index 795eb899f..0429a4637 100644 --- a/tests/testapp/views.py +++ b/tests/testapp/views.py @@ -236,11 +236,7 @@ class TemplateDisabledOrgWriteAllowedDetailView(TemplateDetailView): class ProtectedTemplateDetailView( ProtectedAPIMixin, FilterByOrganizationManaged, RetrieveUpdateDestroyAPIView ): - """ - Uses ProtectedAPIMixin directly, with no permission_classes/ - authentication_classes override, to prove the disabled-organization - guard is inherited automatically rather than manually re-declared. - """ + """Use the mixin defaults to cover inherited disabled-org protection.""" serializer_class = TemplateSerializer queryset = Template.objects.all() From 2e870ef1280639282f7b372ff1fbd712d20f27df Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Mon, 10 Aug 2026 14:49:57 +0530 Subject: [PATCH 16/34] [ci] Fixed QA issues --- openwisp_users/multitenancy.py | 3 ++- openwisp_users/tests/test_api/test_api.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/openwisp_users/multitenancy.py b/openwisp_users/multitenancy.py index 079076502..c423981f1 100644 --- a/openwisp_users/multitenancy.py +++ b/openwisp_users/multitenancy.py @@ -89,7 +89,8 @@ def has_change_permission(self, request, obj=None): def get_inline_instances(self, request, obj=None): """ - Disable add/change for inlines on objects from disabled organizations while keeping delete. + Disable add/change for inlines on objects from disabled organizations + while keeping delete. """ inlines = super().get_inline_instances(request, obj) if obj is None or not self.disabled_organization_write_protection: diff --git a/openwisp_users/tests/test_api/test_api.py b/openwisp_users/tests/test_api/test_api.py index d09e6eb40..f01155b89 100644 --- a/openwisp_users/tests/test_api/test_api.py +++ b/openwisp_users/tests/test_api/test_api.py @@ -840,8 +840,8 @@ def test_patch_resend_disabled_org_membership_deletes_it_api(self): # Resending an unchanged membership exercises the toggle-delete contract. data = {"organization_users": [{"is_admin": False, "organization": org1.pk}]} r = self.client.patch(path, data, content_type="application/json") - self.assertEqual(r.status_code, 400) - self.assertIn("does not exist or is disabled", str(r.data)) + self.assertEqual(r.status_code, 200) + self.assertEqual(OrganizationUser.objects.count(), 0) def test_patch_user_org_membership_without_is_admin_preserves_it_api(self): user1 = self._create_user(username="user1", email="user1@email.com") From f954402fd18c7bd21fea08c009ea8ecb65044a7d Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Mon, 10 Aug 2026 16:08:11 +0530 Subject: [PATCH 17/34] [feature] Added singals for organizations disabled and enabled --- docs/developer/misc-utils.rst | 33 +++++++++++++++++++ openwisp_users/base/models.py | 15 +++++++++ openwisp_users/signals.py | 10 ++++++ openwisp_users/tests/test_models.py | 51 ++++++++++++++++++++++++++++- 4 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 openwisp_users/signals.py diff --git a/docs/developer/misc-utils.rst b/docs/developer/misc-utils.rst index 481055f7e..bb689e4d0 100644 --- a/docs/developer/misc-utils.rst +++ b/docs/developer/misc-utils.rst @@ -311,3 +311,36 @@ Add the validator to the ``AUTH_PASSWORD_VALIDATORS`` Django setting: "NAME": "openwisp_users.password_validation.PasswordReuseValidator", }, ] + +Signals +------- + +.. include:: /partials/signals-note.rst + +``organization_disabled`` +~~~~~~~~~~~~~~~~~~~~~~~~~ + +**Path**: ``openwisp_users.signals.organization_disabled`` + +**Arguments**: + +- ``instance``: the organization instance that was disabled + +Emitted after an organization's ``is_active`` field changes from ``True`` +to ``False`` and the change has been committed to the database. + +This signal is not emitted when an organization is created. + +``organization_enabled`` +~~~~~~~~~~~~~~~~~~~~~~~~ + +**Path**: ``openwisp_users.signals.organization_enabled`` + +**Arguments**: + +- ``instance``: the organization instance that was enabled + +Emitted after an organization's ``is_active`` field changes from ``False`` +to ``True`` and the change has been committed to the database. + +This signal is not emitted when an organization is created. diff --git a/openwisp_users/base/models.py b/openwisp_users/base/models.py index 0cf2229bd..c169926f1 100644 --- a/openwisp_users/base/models.py +++ b/openwisp_users/base/models.py @@ -22,6 +22,7 @@ from openwisp_utils.admin_theme.email import send_email from .. import settings as app_settings +from ..signals import organization_disabled, organization_enabled from ..utils import throttle_email_batch logger = logging.getLogger(__name__) @@ -470,6 +471,10 @@ class BaseOrganization(models.Model): email = models.EmailField(_("email"), blank=True) url = models.URLField(_("URL"), blank=True) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._initial_is_active = self.is_active + def __str__(self): value = self.name if not self.is_active: @@ -479,6 +484,16 @@ def __str__(self): class Meta: abstract = True + def save(self, *args, **kwargs): + is_new = self._state.adding + super().save(*args, **kwargs) + if not is_new and self.is_active != self._initial_is_active: + signal = organization_enabled if self.is_active else organization_disabled + transaction.on_commit( + lambda: signal.send(sender=self.__class__, instance=self) + ) + self._initial_is_active = self.is_active + def add_user(self, user, is_admin=False, **kwargs): """ We override this method from the upstream dependency to diff --git a/openwisp_users/signals.py b/openwisp_users/signals.py new file mode 100644 index 000000000..48dbc8985 --- /dev/null +++ b/openwisp_users/signals.py @@ -0,0 +1,10 @@ +from django.dispatch import Signal + +organization_disabled = Signal() +organization_disabled.__doc__ = """ +Providing arguments: ['instance'] +""" +organization_enabled = Signal() +organization_enabled.__doc__ = """ +Providing arguments: ['instance'] +""" diff --git a/openwisp_users/tests/test_models.py b/openwisp_users/tests/test_models.py index faf1f79b2..2daf9475e 100644 --- a/openwisp_users/tests/test_models.py +++ b/openwisp_users/tests/test_models.py @@ -7,7 +7,7 @@ from django.core.exceptions import ValidationError from django.db.models.signals import post_save from django.templatetags.l10n import localize -from django.test import TestCase, override_settings +from django.test import TestCase, TransactionTestCase, override_settings from django.urls import reverse from django.utils.timezone import localdate, localtime, now, timedelta from freezegun import freeze_time @@ -16,6 +16,7 @@ from openwisp_utils.tests import catch_signal from .. import settings as app_settings +from ..signals import organization_disabled, organization_enabled from ..tasks import ( deactivate_expired_users, expiration_reminder_email, @@ -1348,3 +1349,51 @@ def test_expiration_reminder_email_recipient_selection(self): expiration_reminder_email, None, ) + + +class TestOrganizationSignalsTransaction(TestOrganizationMixin, TransactionTestCase): + def test_organization_disabled_signal(self): + org = self._create_org(name="org-to-disable") + with ( + catch_signal(organization_disabled) as disabled_handler, + catch_signal(organization_enabled) as enabled_handler, + ): + org.is_active = False + org.save() + disabled_handler.assert_called_once_with( + signal=organization_disabled, sender=Organization, instance=org + ) + enabled_handler.assert_not_called() + + def test_organization_enabled_signal(self): + org = self._create_org(name="org-to-enable", is_active=False) + with ( + catch_signal(organization_disabled) as disabled_handler, + catch_signal(organization_enabled) as enabled_handler, + ): + org.is_active = True + org.save() + enabled_handler.assert_called_once_with( + signal=organization_enabled, sender=Organization, instance=org + ) + disabled_handler.assert_not_called() + + def test_organization_active_state_signal_not_sent_on_unrelated_change(self): + org = self._create_org(name="org-unrelated-change") + with ( + catch_signal(organization_disabled) as disabled_handler, + catch_signal(organization_enabled) as enabled_handler, + ): + org.description = "updated description" + org.save() + disabled_handler.assert_not_called() + enabled_handler.assert_not_called() + + def test_organization_active_state_signal_not_sent_on_creation(self): + with ( + catch_signal(organization_disabled) as disabled_handler, + catch_signal(organization_enabled) as enabled_handler, + ): + self._create_org(name="new-org", is_active=False) + disabled_handler.assert_not_called() + enabled_handler.assert_not_called() From 42297972ffaa56b36963826c8ced45b214cba640 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Thu, 13 Aug 2026 17:18:05 +0530 Subject: [PATCH 18/34] [chores] Fixed failing tests --- openwisp_users/tests/test_admin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openwisp_users/tests/test_admin.py b/openwisp_users/tests/test_admin.py index c24149a02..b7b952cac 100644 --- a/openwisp_users/tests/test_admin.py +++ b/openwisp_users/tests/test_admin.py @@ -2135,6 +2135,7 @@ def _base_params(): params.pop("_password", None) params.pop("last_login") params.pop("password_updated") + params.pop("password_based_token") params.pop("expiration_date", None) params = self._additional_params_pop(params) params.update(self.add_user_inline_params) From 2a6ebf453d02b599016781caded6766bf9e35982 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Fri, 14 Aug 2026 14:37:28 +0530 Subject: [PATCH 19/34] [fix] Fixes by @coderabbitai --- .../developer/django-rest-framework-utils.rst | 5 ++- docs/user/rest-api.rst | 8 +++- openwisp_users/api/mixins.py | 41 +++++++++++-------- openwisp_users/base/models.py | 10 ++++- .../openwisp-users/js/org-autocomplete.js | 22 +++++----- openwisp_users/tests/test_admin.py | 14 +++---- openwisp_users/tests/test_api/__init__.py | 1 + openwisp_users/tests/test_api/test_api.py | 36 ++++++++++++++++ openwisp_users/tests/test_models.py | 12 ++++++ .../testapp/tests/test_permission_classes.py | 29 ++++++++++++- tests/testapp/tests/test_selenium.py | 17 ++++++++ 11 files changed, 152 insertions(+), 43 deletions(-) diff --git a/docs/developer/django-rest-framework-utils.rst b/docs/developer/django-rest-framework-utils.rst index 87008b7e8..02c6f5afc 100644 --- a/docs/developer/django-rest-framework-utils.rst +++ b/docs/developer/django-rest-framework-utils.rst @@ -152,8 +152,9 @@ are not organization-scoped must opt out explicitly. ``has_object_permission``, which DRF does not call on ``POST``, so it does **not** block creation for a disabled organization. The serializer must exclude disabled organizations from its - ``organization`` field, for example by using a - ``FilterSerializerByOrganization`` mixin or ``Organization.active``. A + ``organization`` field, for example by using + ``FilterSerializerByOrgMembership``, ``FilterSerializerByOrgManaged`` + or ``FilterSerializerByOrgOwned`` mixin, or ``Organization.active``. A plain ``ModelSerializer`` can still create an object for a disabled organization. diff --git a/docs/user/rest-api.rst b/docs/user/rest-api.rst index 3f9a78203..47d504958 100644 --- a/docs/user/rest-api.rst +++ b/docs/user/rest-api.rst @@ -356,7 +356,9 @@ Change User Detail When editing organization memberships, the organization manager flag is represented internally by the ``is_admin`` field in the - ``organization_users`` payload. + ``organization_users`` payload. For an existing membership, omitting + ``is_admin`` leaves it unchanged, changing its value updates the role, + and sending its current value removes the membership. Patch User Detail ~~~~~~~~~~~~~~~~~ @@ -369,7 +371,9 @@ Patch User Detail When patching organization memberships, the organization manager flag is represented internally by the ``is_admin`` field in the - ``organization_users`` payload. + ``organization_users`` payload. For an existing membership, omitting + ``is_admin`` leaves it unchanged, changing its value updates the role, + and sending its current value removes the membership. Delete User ~~~~~~~~~~~ diff --git a/openwisp_users/api/mixins.py b/openwisp_users/api/mixins.py index bc8cc3ab4..a2676840f 100644 --- a/openwisp_users/api/mixins.py +++ b/openwisp_users/api/mixins.py @@ -33,6 +33,17 @@ def org_field(self): def organization_lookup(self): return f"{self.org_field}__in" + def _organization_relation_is_valid(self, model): + for part in self.org_field.split("__"): + try: + field = model._meta.get_field(part) + except FieldDoesNotExist: + return False + if not (field.concrete and (field.many_to_one or field.one_to_one)): + return False + model = field.related_model + return True + class SharedObjectsLookup: @property @@ -74,22 +85,6 @@ def get_queryset(self): return qs return self.get_organization_queryset(qs) - def _organization_relation_is_valid(self, model): - """ - Check the relation before queryset evaluation can surface an invalid - ``select_related`` path. - """ - for part in self.org_field.split("__"): - try: - field = model._meta.get_field(part) - except FieldDoesNotExist: - return False - related_model = getattr(field, "related_model", None) - if related_model is None: - return False - model = related_model - return True - def get_organization_queryset(self, qs): if self.request.user.is_anonymous: return @@ -140,7 +135,9 @@ def assert_parent_exists(self): parent_queryset = self.get_parent_queryset() if not self.request.user.is_superuser: parent_queryset = self.get_organization_queryset(parent_queryset) - if getattr(self, "select_related_organization", True): + if getattr( + self, "select_related_organization", True + ) and self._organization_relation_is_valid(parent_queryset.model): parent_queryset = parent_queryset.select_related(self.org_field) try: assert parent_queryset.exists() @@ -211,6 +208,14 @@ def _filter_organization_field(self, field, organization_filter): # Keep disabled organizations out of writable relation fields. queryset = field.queryset.filter(is_active=True) field.error_messages["does_not_exist"] = DISABLED_ORGANIZATION_ERROR_MESSAGE + view = self.context.get("view") + organization = getattr(self.instance, "organization", None) + if ( + getattr(view, "allow_disabled_organization_writes", False) + and organization is not None + and not organization.is_active + ): + queryset |= field.queryset.filter(pk=organization.pk) if organization_filter is not None: field.allow_null = False queryset = queryset.filter(pk__in=organization_filter) @@ -227,7 +232,7 @@ def _filter_related_field(self, field, organization_filter): else: conditions &= Q(**{self.organization_lookup: organization_filter}) if self.include_shared: - conditions |= Q(organization__isnull=True) + conditions |= Q(**{f"{self.org_field}__isnull": True}) field.queryset = queryset.filter(conditions) def __init__(self, *args, **kwargs): diff --git a/openwisp_users/base/models.py b/openwisp_users/base/models.py index c169926f1..07529a322 100644 --- a/openwisp_users/base/models.py +++ b/openwisp_users/base/models.py @@ -486,13 +486,19 @@ class Meta: def save(self, *args, **kwargs): is_new = self._state.adding + update_fields = kwargs.get("update_fields") super().save(*args, **kwargs) - if not is_new and self.is_active != self._initial_is_active: + if ( + not is_new + and (update_fields is None or "is_active" in update_fields) + and self.is_active != self._initial_is_active + ): signal = organization_enabled if self.is_active else organization_disabled transaction.on_commit( lambda: signal.send(sender=self.__class__, instance=self) ) - self._initial_is_active = self.is_active + if update_fields is None or "is_active" in update_fields: + self._initial_is_active = self.is_active def add_user(self, user, is_admin=False, **kwargs): """ diff --git a/openwisp_users/static/openwisp-users/js/org-autocomplete.js b/openwisp_users/static/openwisp-users/js/org-autocomplete.js index 137c99013..278a8e432 100644 --- a/openwisp_users/static/openwisp-users/js/org-autocomplete.js +++ b/openwisp_users/static/openwisp-users/js/org-autocomplete.js @@ -10,17 +10,17 @@ // Hence, we need to update the value of the selected option before // submission of the form. // - // Every organization autocomplete widget (top-level or inline) renders - // `data-field-name="organization"`, so we bind to each one instead of a - // single hardcoded id. - $("select[data-field-name='organization']").each(function () { - var orgSelect = $(this); - orgSelect.closest("form").on("submit", function () { - var selected = orgSelect.find("option:selected"); - if (selected.val() === "null") { - selected.val(""); - } - }); + // Find the organization fields at submit time so dynamically added + // inlines are included too. + $("form").on("submit", function () { + $(this) + .find("select[data-field-name='organization'] option:selected") + .each(function () { + var selected = $(this); + if (selected.val() === "null") { + selected.val(""); + } + }); }); // Auto-selection only applies to the single top-level organization field diff --git a/openwisp_users/tests/test_admin.py b/openwisp_users/tests/test_admin.py index b7b952cac..2b2fa9ef8 100644 --- a/openwisp_users/tests/test_admin.py +++ b/openwisp_users/tests/test_admin.py @@ -2034,11 +2034,12 @@ def test_disabled_org_inlines_centrally_write_protected(self): disabled_org = self._create_org(name="disabled-inline-org", is_active=False) request = RequestFactory().get("/") request.user = self._get_admin() - inlines = list(org_admin.get_inline_instances(request, disabled_org)) - for inline in inlines: - for excluded_inlines in self._get_disabled_org_test_excluded_inline(): - if isinstance(inline, excluded_inlines): - inlines.remove(inline) + excluded = tuple(self._get_disabled_org_test_excluded_inline()) + inlines = [ + inline + for inline in org_admin.get_inline_instances(request, disabled_org) + if not (excluded and isinstance(inline, excluded)) + ] self._test_disabled_org_admin_inline_readonly( org_admin, disabled_org, active_obj=active_org, inline_admins=inlines ) @@ -2106,8 +2107,7 @@ def test_user_admin_inline_disabled_organization(self): self.assertContains(res, "errors field-organization") self.assertEqual(User.objects.filter(username="disableduserinline").count(), 0) - def test_user_inline_org_picker_excludes_disabled(self): - # The autocomplete endpoint must exclude disabled organizations. + def test_user_inline_org_picker_sets_exclude_disabled_parameter(self): admin = self._get_admin() self.client.force_login(admin) response = self.client.get(reverse(f"admin:{self.app_label}_user_add")) diff --git a/openwisp_users/tests/test_api/__init__.py b/openwisp_users/tests/test_api/__init__.py index ebc9b60a4..92f17da8b 100644 --- a/openwisp_users/tests/test_api/__init__.py +++ b/openwisp_users/tests/test_api/__init__.py @@ -96,6 +96,7 @@ def _test_disabled_org_api_update( for method in methods: with self.subTest(method=method): if unchanged: + obj.refresh_from_db() before = getattr(obj, unchanged_field) response = getattr(self.client, method)( url, data=payload, content_type="application/json", **auth diff --git a/openwisp_users/tests/test_api/test_api.py b/openwisp_users/tests/test_api/test_api.py index f01155b89..654c101cc 100644 --- a/openwisp_users/tests/test_api/test_api.py +++ b/openwisp_users/tests/test_api/test_api.py @@ -786,6 +786,42 @@ def test_patch_user_organization_users_disabled_org_api(self): self.assertEqual(r.status_code, 400) self.assertEqual(OrganizationUser.objects.filter(organization=org1).count(), 0) + def test_org_manager_cannot_create_memberships_for_inaccessible_orgs(self): + managed_org = self._create_org(name="managed-org") + disabled_org = self._create_org(name="disabled-org", is_active=False) + unmanaged_org = self._create_org(name="unmanaged-org") + manager = self._create_user(username="manager", email="manager@example.com") + manager.groups.set(Group.objects.filter(name="Administrator")) + self._create_org_user(user=manager, organization=managed_org, is_admin=True) + self.client.force_login(manager) + path = reverse("users:user_list") + + for name, organization in ( + ("disabled", disabled_org), + ("unmanaged", unmanaged_org), + ): + with self.subTest(organization=name): + username = f"{name}-membership" + response = self.client.post( + path, + { + "username": username, + "email": f"{username}@example.com", + "password": "password", + "organization_users": { + "is_admin": False, + "organization": organization.pk, + }, + }, + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + self.assertEqual(User.objects.filter(username=username).exists(), False) + self.assertEqual( + OrganizationUser.objects.filter(organization=organization).exists(), + False, + ) + def test_patch_user_detail_api(self): user = self._get_user() path = reverse("users:user_detail", args=(user.pk,)) diff --git a/openwisp_users/tests/test_models.py b/openwisp_users/tests/test_models.py index 2daf9475e..68987cb2b 100644 --- a/openwisp_users/tests/test_models.py +++ b/openwisp_users/tests/test_models.py @@ -1389,6 +1389,18 @@ def test_organization_active_state_signal_not_sent_on_unrelated_change(self): disabled_handler.assert_not_called() enabled_handler.assert_not_called() + def test_organization_active_state_signal_respects_update_fields(self): + org = self._create_org(name="org-update-fields") + org.is_active = False + org.name = "renamed-org" + with catch_signal(organization_disabled) as disabled_handler: + org.save(update_fields={"name"}) + disabled_handler.assert_not_called() + org.save(update_fields={"is_active"}) + disabled_handler.assert_called_once_with( + signal=organization_disabled, sender=Organization, instance=org + ) + def test_organization_active_state_signal_not_sent_on_creation(self): with ( catch_signal(organization_disabled) as disabled_handler, diff --git a/tests/testapp/tests/test_permission_classes.py b/tests/testapp/tests/test_permission_classes.py index 1d8226c5d..8b323bc3a 100644 --- a/tests/testapp/tests/test_permission_classes.py +++ b/tests/testapp/tests/test_permission_classes.py @@ -4,9 +4,11 @@ from django.contrib.auth.models import Permission from django.test import RequestFactory, TestCase from django.urls import reverse +from rest_framework.generics import ListAPIView from rest_framework.test import APIRequestFactory from swapper import load_model +from openwisp_users.api.mixins import FilterByOrganizationManaged, FilterByParentManaged from openwisp_users.api.permissions import DisabledOrgReadOnly from openwisp_users.api.throttling import AuthRateThrottle @@ -412,7 +414,7 @@ def test_disabled_org_read_only_permission(self): with self.subTest("opt-out view allows write"): response = self.client.put( allowed_url, - data={"name": "renamed"}, + data={"name": "renamed", "organization": str(org.pk)}, content_type="application/json", **auth, ) @@ -654,3 +656,28 @@ def test_disabled_org_read_only_select_related_valid_field(self): view.request = request queryset = view.get_queryset() self.assertIn("organization", queryset.query.select_related) + + def test_invalid_select_related_organization_paths_are_skipped(self): + class ManyToManyOrganizationView(FilterByOrganizationManaged, ListAPIView): + queryset = Shelf.objects.all() + organization_field = "tags" + + class ManyToManyParentView(FilterByParentManaged, ListAPIView): + queryset = Shelf.objects.all() + organization_field = "tags" + + def get_parent_queryset(self): + return Shelf.objects.all() + + org = self._get_org() + shelf = Shelf(name="m2m-lookup-shelf", organization=org) + shelf.full_clean() + shelf.save() + admin = self._get_admin() + request = APIRequestFactory().get("/") + request.user = admin + for view_class in (ManyToManyOrganizationView, ManyToManyParentView): + with self.subTest(view=view_class.__name__): + view = view_class() + view.request = request + self.assertEqual(view.get_queryset().count(), 1) diff --git a/tests/testapp/tests/test_selenium.py b/tests/testapp/tests/test_selenium.py index f4c130661..c2477c33d 100644 --- a/tests/testapp/tests/test_selenium.py +++ b/tests/testapp/tests/test_selenium.py @@ -166,3 +166,20 @@ def test_user_add_form_does_not_hang(self): ) ) self.logout() + + def test_dynamic_organization_inline_normalizes_shared_value_on_submit(self): + path = reverse(f"admin:{User._meta.app_label}_user_add") + self.login(username=self.admin_username, password=self.admin_password) + self.open(path) + value = self.web_driver.execute_script(""" + const form = document.querySelector("form"); + const field = document.createElement("select"); + field.dataset.fieldName = "organization"; + field.append(new Option("Shared systemwide", "null", true, true)); + form.append(field); + form.addEventListener("submit", event => event.preventDefault(), {once: true}); + form.dispatchEvent(new Event("submit", {bubbles: true, cancelable: true})); + return field.value; + """) + self.assertEqual(value, "") + self.logout() From 2e8a64592993b3dd9f8875c5fe494b3226828fe3 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Fri, 14 Aug 2026 15:23:45 +0530 Subject: [PATCH 20/34] [chores] Fixed QA issues --- tests/testapp/tests/test_selenium.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/testapp/tests/test_selenium.py b/tests/testapp/tests/test_selenium.py index c2477c33d..cf9b4860b 100644 --- a/tests/testapp/tests/test_selenium.py +++ b/tests/testapp/tests/test_selenium.py @@ -177,7 +177,9 @@ def test_dynamic_organization_inline_normalizes_shared_value_on_submit(self): field.dataset.fieldName = "organization"; field.append(new Option("Shared systemwide", "null", true, true)); form.append(field); - form.addEventListener("submit", event => event.preventDefault(), {once: true}); + form.addEventListener( + "submit", event => event.preventDefault(), {once: true} + ); form.dispatchEvent(new Event("submit", {bubbles: true, cancelable: true})); return field.value; """) From 4387384f242a53fbf99037dfe205ce17df61065f Mon Sep 17 00:00:00 2001 From: Federico Capoano Date: Fri, 14 Aug 2026 19:19:07 -0300 Subject: [PATCH 21/34] [tests] Added failing tests --- openwisp_users/tests/test_models.py | 8 +++++++ tests/testapp/tests/test_multitenancy.py | 23 +++++++++++++++++++ .../testapp/tests/test_permission_classes.py | 11 +++++++++ 3 files changed, 42 insertions(+) diff --git a/openwisp_users/tests/test_models.py b/openwisp_users/tests/test_models.py index 68987cb2b..93525c119 100644 --- a/openwisp_users/tests/test_models.py +++ b/openwisp_users/tests/test_models.py @@ -404,6 +404,14 @@ def test_organization_user_string_representation(self): with self.subTest("Test user first and last names are empty"): self.assertEqual(str(org_user), f"{user.username} ({org.name})") + def test_deferred_organization_queryset_num_queries(self): + for index in range(3): + self._create_org(name=f"deferred-org-{index}") + with self.assertNumQueries(1): + list( + Organization.objects.filter(name__startswith="deferred-org-").only("id") + ) + def test_add_user(self): org = self._get_org() user = self._create_user() diff --git a/tests/testapp/tests/test_multitenancy.py b/tests/testapp/tests/test_multitenancy.py index 321900ed9..f3fc9e457 100644 --- a/tests/testapp/tests/test_multitenancy.py +++ b/tests/testapp/tests/test_multitenancy.py @@ -98,6 +98,29 @@ def test_shelf_disabled_organization_admin_guard(self): roles=("superuser",), ) + def test_disabled_organization_mutating_action_is_blocked(self): + class ShelfActionAdmin(MultitenantAdminMixin, admin.ModelAdmin): + actions = ["rename_selected"] + + @admin.action(permissions=["change"]) + def rename_selected(self, request, queryset): + queryset.update(name="renamed-shelf") + + org = self._get_org() + shelf = self._create_shelf(name="action-guard-shelf", organization=org) + org.is_active = False + org.save() + request = RequestFactory().post( + "/", + {"action": "rename_selected", "_selected_action": [str(shelf.pk)]}, + ) + request.user = self._get_admin() + model_admin = ShelfActionAdmin(Shelf, admin.site) + self.assertTrue(model_admin.has_delete_permission(request, shelf)) + model_admin.response_action(request, Shelf.objects.filter(pk=shelf.pk)) + shelf.refresh_from_db() + self.assertEqual(shelf.name, "action-guard-shelf") + def test_disabled_org_admin_crud_org_admin_loses_access(self): org = self._create_org(name="admin-mixin-org-oa") shelf = self._create_shelf(name="admin-mixin-shelf-oa", organization=org) diff --git a/tests/testapp/tests/test_permission_classes.py b/tests/testapp/tests/test_permission_classes.py index 8b323bc3a..ab2c31c9c 100644 --- a/tests/testapp/tests/test_permission_classes.py +++ b/tests/testapp/tests/test_permission_classes.py @@ -420,6 +420,17 @@ def test_disabled_org_read_only_permission(self): ) self.assertEqual(response.status_code, 200) + with self.subTest("full update keeps the disabled organization"): + response = self.client.put( + allowed_url, + data={"name": "renamed", "organization": str(org.pk)}, + content_type="application/json", + **auth, + ) + self.assertEqual(response.status_code, 200) + template.refresh_from_db() + self.assertEqual(template.organization_id, org.pk) + with self.subTest("shared object unaffected"): shared_template = self._create_template( name="shared-template", organization=None From 84b2980c0551c4b078a0091b66b639b242d1d67d Mon Sep 17 00:00:00 2001 From: Federico Capoano Date: Fri, 14 Aug 2026 20:18:22 -0300 Subject: [PATCH 22/34] [tests] More failing tests --- openwisp_users/tests/test_models.py | 38 +++++++++++++++++++ .../testapp/tests/test_permission_classes.py | 29 ++++++++++---- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/openwisp_users/tests/test_models.py b/openwisp_users/tests/test_models.py index 93525c119..93705652f 100644 --- a/openwisp_users/tests/test_models.py +++ b/openwisp_users/tests/test_models.py @@ -5,6 +5,7 @@ from django.contrib.auth import get_user_model from django.core import mail from django.core.exceptions import ValidationError +from django.db import transaction from django.db.models.signals import post_save from django.templatetags.l10n import localize from django.test import TestCase, TransactionTestCase, override_settings @@ -1409,6 +1410,43 @@ def test_organization_active_state_signal_respects_update_fields(self): signal=organization_disabled, sender=Organization, instance=org ) + def test_organization_signal_transaction_state(self): + with self.subTest("callbacks receive the state of each transition"): + org = self._create_org(name="org-multiple-transitions") + disabled_states = [] + enabled_states = [] + with ( + catch_signal(organization_disabled) as disabled_handler, + catch_signal(organization_enabled) as enabled_handler, + ): + disabled_handler.side_effect = lambda **kwargs: disabled_states.append( + kwargs["instance"].is_active + ) + enabled_handler.side_effect = lambda **kwargs: enabled_states.append( + kwargs["instance"].is_active + ) + with transaction.atomic(): + org.is_active = False + org.save() + org.is_active = True + org.save() + self.assertEqual(disabled_states, [False]) + self.assertEqual(enabled_states, [True]) + + with self.subTest("rollback does not suppress the retried transition"): + org = self._create_org(name="org-rollback-retry") + with catch_signal(organization_disabled) as disabled_handler: + with self.assertRaises(RuntimeError): + with transaction.atomic(): + org.is_active = False + org.save() + raise RuntimeError + org.is_active = False + org.save() + disabled_handler.assert_called_once_with( + signal=organization_disabled, sender=Organization, instance=org + ) + def test_organization_active_state_signal_not_sent_on_creation(self): with ( catch_signal(organization_disabled) as disabled_handler, diff --git a/tests/testapp/tests/test_permission_classes.py b/tests/testapp/tests/test_permission_classes.py index ab2c31c9c..519f4ac72 100644 --- a/tests/testapp/tests/test_permission_classes.py +++ b/tests/testapp/tests/test_permission_classes.py @@ -5,7 +5,7 @@ from django.test import RequestFactory, TestCase from django.urls import reverse from rest_framework.generics import ListAPIView -from rest_framework.test import APIRequestFactory +from rest_framework.test import APIRequestFactory, force_authenticate from swapper import load_model from openwisp_users.api.mixins import FilterByOrganizationManaged, FilterByParentManaged @@ -14,7 +14,7 @@ from ..models import Shelf, Template from ..serializers import BookManagerSerializer -from ..views import TemplateDetailView +from ..views import TemplateDetailView, TemplateDisabledOrgWriteAllowedDetailView from .mixins import TestMultitenancyMixin User = get_user_model() @@ -420,12 +420,25 @@ def test_disabled_org_read_only_permission(self): ) self.assertEqual(response.status_code, 200) - with self.subTest("full update keeps the disabled organization"): - response = self.client.put( - allowed_url, - data={"name": "renamed", "organization": str(org.pk)}, - content_type="application/json", - **auth, + with self.subTest("manager update keeps the disabled organization"): + + class ManagerAllowedTemplateDetailView( + TemplateDisabledOrgWriteAllowedDetailView + ): + permission_classes = () + + def get_queryset(self): + return Template.objects.all() + + manager = self._create_administrator() + request = APIRequestFactory().put( + "/", + {"name": "renamed", "organization": str(org.pk)}, + format="json", + ) + force_authenticate(request, user=manager) + response = ManagerAllowedTemplateDetailView.as_view()( + request, pk=template.pk ) self.assertEqual(response.status_code, 200) template.refresh_from_db() From 77d640bbb3366f788a0e4313e6d23f6f409e961b Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Mon, 17 Aug 2026 19:44:14 +0530 Subject: [PATCH 23/34] [fix] Made requested changes --- docs/developer/admin-utils.rst | 15 +++++++ openwisp_users/admin.py | 16 ++++--- openwisp_users/api/mixins.py | 16 ++++++- openwisp_users/api/permissions.py | 6 ++- openwisp_users/apps.py | 26 +++++------ openwisp_users/base/models.py | 32 +++++++------ openwisp_users/multitenancy.py | 48 +++++++++++++++++--- openwisp_users/tests/test_api/test_api.py | 38 ++++++++-------- openwisp_users/tests/test_models.py | 52 +++++++++++++++++----- tests/testapp/tests/test_filter_classes.py | 24 ++++++++++ tests/testapp/tests/test_multitenancy.py | 43 ++++++++++++++++++ tests/testapp/tests/test_selenium.py | 40 +++++++++++------ 12 files changed, 270 insertions(+), 86 deletions(-) diff --git a/docs/developer/admin-utils.rst b/docs/developer/admin-utils.rst index a5712e8bc..b2e61a731 100644 --- a/docs/developer/admin-utils.rst +++ b/docs/developer/admin-utils.rst @@ -64,6 +64,21 @@ available, even when an inline does not use the mixin. Set ``disabled_organization_write_protection = False`` on the parent admin or on an individual inline to opt out. +Custom admin actions are also blocked for disabled-organization objects, +except for deletion actions. To allow a specific lifecycle action while +keeping the rest of the protection enabled, list its name in +``disabled_organization_action_exclusions``: + +.. code-block:: python + + class DeviceAdmin(MultitenantAdminMixin, admin.ModelAdmin): + disabled_organization_action_exclusions = ("deactivate_device",) + +The mixin resolves an object's organization from ``organization`` by +default and follows ``multitenant_parent`` when configured. Admins using +another relation can override ``get_object_organization()`` to return the +related organization. + ``MultitenantOrgFilter`` ------------------------ diff --git a/openwisp_users/admin.py b/openwisp_users/admin.py index a16669bff..6cbfe6ea0 100644 --- a/openwisp_users/admin.py +++ b/openwisp_users/admin.py @@ -129,12 +129,15 @@ def add_fields(self, form, index): ): org_field = form.fields.get("organization") if org_field is not None: - org_field.disabled = True - org_field.queryset = Organization.objects.filter( + # The formset queryset excludes disabled organizations, + # so the current membership's organization must be added + # back or the disabled field fails validation against it. + org_model = org_field.queryset.model + org_field.queryset = org_field.queryset | org_model.objects.filter( pk=instance.organization_id ) - if "is_admin" in form.fields: - form.fields["is_admin"].disabled = True + for field in form.fields.values(): + field.disabled = True class OrganizationUserInline(admin.StackedInline): @@ -639,7 +642,7 @@ def has_change_permission(self, request, obj=None): if obj and not request.user.is_superuser and not request.user.is_manager(obj): return False if obj and not obj.is_active: - return True + return request.user.is_superuser return super().has_change_permission(request, obj) def get_readonly_fields(self, request, obj=None): @@ -654,6 +657,9 @@ def get_readonly_fields(self, request, obj=None): for f in self.model._meta.local_fields if f.editable and f.name != "is_active" ] + editable_fields.extend( + f.name for f in self.model._meta.local_many_to_many if f.editable + ) fields = list(fields) + [f for f in editable_fields if f not in fields] return fields diff --git a/openwisp_users/api/mixins.py b/openwisp_users/api/mixins.py index a2676840f..24fe2157f 100644 --- a/openwisp_users/api/mixins.py +++ b/openwisp_users/api/mixins.py @@ -42,7 +42,7 @@ def _organization_relation_is_valid(self, model): if not (field.concrete and (field.many_to_one or field.one_to_one)): return False model = field.related_model - return True + return model == Organization class SharedObjectsLookup: @@ -218,7 +218,14 @@ def _filter_organization_field(self, field, organization_filter): queryset |= field.queryset.filter(pk=organization.pk) if organization_filter is not None: field.allow_null = False - queryset = queryset.filter(pk__in=organization_filter) + allowed_organizations = Q(pk__in=organization_filter) + if ( + getattr(view, "allow_disabled_organization_writes", False) + and organization is not None + and not organization.is_active + ): + allowed_organizations |= Q(pk=organization.pk) + queryset = queryset.filter(allowed_organizations) field.queryset = queryset def _filter_related_field(self, field, organization_filter): @@ -242,6 +249,11 @@ def __init__(self, *args, **kwargs): if "request" in self.context: self.filter_fields() + def bind(self, field_name, parent): + super().bind(field_name, parent) + if "request" in self.context: + self.filter_fields() + class FilterSerializerByOrgMembership(FilterSerializerByOrganization): """ diff --git a/openwisp_users/api/permissions.py b/openwisp_users/api/permissions.py index 2c080423a..09d9ee1b4 100644 --- a/openwisp_users/api/permissions.py +++ b/openwisp_users/api/permissions.py @@ -38,6 +38,8 @@ def has_object_permission(self, request, view, obj): len(request.user.organizations_managed) >= 1 or len(request.user.organizations_owned) >= 1 ) + if not isinstance(organization, Organization): + return False return self.validate_membership(request.user, organization) def has_permission(self, request, view): @@ -116,7 +118,9 @@ def has_object_permission(self, request, view, obj): except AttributeError: # Do not fail open on a bad relation path; unrelated views opt out. return False - return organization is None or organization.is_active + return organization is None or ( + isinstance(organization, Organization) and organization.is_active + ) class DjangoModelPermissions(ObjectOrganizationMixin, BaseDjangoModelPermissions): diff --git a/openwisp_users/apps.py b/openwisp_users/apps.py index d190538ee..dd5a2be80 100644 --- a/openwisp_users/apps.py +++ b/openwisp_users/apps.py @@ -16,6 +16,7 @@ from . import settings as app_settings from .auth import SESAME_BACKEND, record_password_based_login +from .signals import organization_disabled, organization_enabled logger = logging.getLogger(__name__) @@ -108,11 +109,15 @@ def connect_receivers(self): (post_delete, "post_delete"), ] - pre_save.connect( - self.handle_org_is_active_change, - sender=Organization, - dispatch_uid="handle_org_is_active_change", - ) + for signal, name in ( + (organization_disabled, "organization_disabled"), + (organization_enabled, "organization_enabled"), + ): + signal.connect( + self.handle_org_is_active_change, + sender=Organization, + dispatch_uid=f"handle_org_is_active_change_{name}", + ) for model in [OrganizationUser, OrganizationOwner]: for signal, name in signal_tuples: @@ -175,18 +180,9 @@ def handle_allauth_login(cls, request, sociallogin=None, **kwargs): @classmethod def handle_org_is_active_change(cls, instance, **kwargs): - if instance._state.adding: - # If it's a new organization, we don't need to update any cache - return - Organization = instance._meta.model - try: - old_instance = Organization.objects.only("is_active").get(pk=instance.pk) - except Organization.DoesNotExist: - return from .tasks import invalidate_org_membership_cache - if instance.is_active != old_instance.is_active: - invalidate_org_membership_cache.delay(instance.pk) + invalidate_org_membership_cache.delay(instance.pk) @classmethod def pre_save_update_organizations_dict(cls, instance, **kwargs): diff --git a/openwisp_users/base/models.py b/openwisp_users/base/models.py index 07529a322..0a551292d 100644 --- a/openwisp_users/base/models.py +++ b/openwisp_users/base/models.py @@ -1,3 +1,4 @@ +import copy import logging import uuid from smtplib import SMTPException @@ -471,10 +472,6 @@ class BaseOrganization(models.Model): email = models.EmailField(_("email"), blank=True) url = models.URLField(_("URL"), blank=True) - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._initial_is_active = self.is_active - def __str__(self): value = self.name if not self.is_active: @@ -487,18 +484,24 @@ class Meta: def save(self, *args, **kwargs): is_new = self._state.adding update_fields = kwargs.get("update_fields") + previous_is_active = None + if not is_new and (update_fields is None or "is_active" in update_fields): + previous_is_active = ( + self.__class__.objects.filter(pk=self.pk) + .values_list("is_active", flat=True) + .first() + ) super().save(*args, **kwargs) if ( not is_new and (update_fields is None or "is_active" in update_fields) - and self.is_active != self._initial_is_active + and self.is_active != previous_is_active ): signal = organization_enabled if self.is_active else organization_disabled + instance = copy.copy(self) transaction.on_commit( - lambda: signal.send(sender=self.__class__, instance=self) + lambda: signal.send(sender=self.__class__, instance=instance) ) - if update_fields is None or "is_active" in update_fields: - self._initial_is_active = self.is_active def add_user(self, user, is_admin=False, **kwargs): """ @@ -511,7 +514,9 @@ def add_user(self, user, is_admin=False, **kwargs): is_admin = True OrganizationUser = load_model("openwisp_users", "OrganizationUser") - org_user = OrganizationUser(user=user, organization=self, is_admin=is_admin) + org_user = OrganizationUser( + user=user, organization=self, is_admin=is_admin, **kwargs + ) org_user.full_clean() org_user.save() return org_user @@ -537,11 +542,10 @@ def clean(self): .first() ) - changed = ( - original is None - or original.organization_id != self.organization_id - or original.user_id != self.user_id - or original.is_admin != self.is_admin + changed = original is None or any( + getattr(original, field.attname) != getattr(self, field.attname) + for field in self._meta.concrete_fields + if field.editable and not field.primary_key ) if changed and self.organization_id is not None: diff --git a/openwisp_users/multitenancy.py b/openwisp_users/multitenancy.py index c423981f1..2ab50d97d 100644 --- a/openwisp_users/multitenancy.py +++ b/openwisp_users/multitenancy.py @@ -1,5 +1,7 @@ +from django.contrib import messages from django.contrib.auth import get_user_model from django.db.models import Q +from django.http import HttpResponseRedirect from django.utils.translation import gettext_lazy as _ from swapper import load_model @@ -22,6 +24,7 @@ class MultitenantAdminMixin(object): multitenant_parent = None # Set False on subclasses that allow writes to disabled-organization objects. disabled_organization_write_protection = True + disabled_organization_action_exclusions = () def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -51,16 +54,22 @@ def get_queryset(self, request): if user.is_superuser: return qs if hasattr(self.model, "organization"): - return qs.filter(organization__in=user.organizations_managed) + return qs.filter( + organization__in=user.organizations_managed, + organization__is_active=True, + ) if self.model.__name__ == "Organization": - return qs.filter(pk__in=user.organizations_managed) + return qs.filter(pk__in=user.organizations_managed, is_active=True) elif not self.multitenant_parent: return qs else: qsarg = "{0}__organization__in".format(self.multitenant_parent) - return qs.filter(**{qsarg: user.organizations_managed}) + active_qsarg = "{0}__organization__is_active".format( + self.multitenant_parent + ) + return qs.filter(**{qsarg: user.organizations_managed, active_qsarg: True}) - def _get_object_organization(self, obj): + def get_object_organization(self, obj): """ Resolve an object's organization, including through ``multitenant_parent``. @@ -82,7 +91,7 @@ def has_change_permission(self, request, obj=None): Block changes to disabled organizations unless the admin opts out. """ if self.disabled_organization_write_protection and obj is not None: - organization = self._get_object_organization(obj) + organization = self.get_object_organization(obj) if organization is not None and not organization.is_active: return False return super().has_change_permission(request, obj) @@ -95,7 +104,7 @@ def get_inline_instances(self, request, obj=None): inlines = super().get_inline_instances(request, obj) if obj is None or not self.disabled_organization_write_protection: return inlines - organization = self._get_object_organization(obj) + organization = self.get_object_organization(obj) if organization is None or organization.is_active: return inlines for inline in inlines: @@ -118,6 +127,31 @@ def has_add_permission(self, request, *args, **kwargs): return False return super().has_add_permission(request, *args, **kwargs) + def response_action(self, request, queryset): + action = request.POST.get("action") + if ( + self.disabled_organization_write_protection + and action not in self.get_disabled_organization_action_exclusions() + and any( + organization is not None and not organization.is_active + for organization in ( + self.get_object_organization(obj) for obj in queryset + ) + ) + ): + self.message_user( + request, + _("Actions cannot modify objects of disabled organizations."), + messages.ERROR, + ) + return HttpResponseRedirect(request.get_full_path()) + return super().response_action(request, queryset) + + def get_disabled_organization_action_exclusions(self): + return {"delete_selected", "delete_selected_overridden"}.union( + self.disabled_organization_action_exclusions + ) + def _edit_form(self, request, form, obj=None): """ Filter form fields by organization and exclude disabled choices. @@ -130,7 +164,7 @@ def _edit_form(self, request, form, obj=None): org_field = fields.get("organization") keep_disabled_org_pk = None if not self.disabled_organization_write_protection and obj is not None: - organization = self._get_object_organization(obj) + organization = self.get_object_organization(obj) if organization is not None and not organization.is_active: keep_disabled_org_pk = organization.pk if org_field: diff --git a/openwisp_users/tests/test_api/test_api.py b/openwisp_users/tests/test_api/test_api.py index 654c101cc..eb022c8f8 100644 --- a/openwisp_users/tests/test_api/test_api.py +++ b/openwisp_users/tests/test_api/test_api.py @@ -89,25 +89,6 @@ def test_organization_detail_nonsuperuser_api(self): r = self.client.get(path) self.assertEqual(r.status_code, 404) - def test_organization_put_api(self): - org1 = self._get_org() - self.assertEqual(org1.name, "test org") - self.assertEqual(org1.description, "") - path = reverse("users:organization_detail", args=(org1.pk,)) - data = { - "name": "test org change", - "is_active": False, - "slug": "test-org-change", - "description": "testing PUT", - "email": "testorg@test.com", - "url": "", - } - with self.assertNumQueries(8): - r = self.client.put(path, data, content_type="application/json") - self.assertEqual(r.status_code, 200) - self.assertEqual(r.data["name"], "test org change") - self.assertEqual(r.data["description"], "testing PUT") - def test_organization_patch_api(self): org1 = self._get_org() self.assertEqual(org1.name, "test org") @@ -1070,3 +1051,22 @@ def get_all_orgs(self): self.assertEqual(r.status_code, 400) self.assertEqual(User.objects.filter(username="tester").count(), 0) self.assertEqual(OrganizationUser.objects.filter(organization=org1).count(), 0) + + def test_organization_put_api(self): + org1 = self._get_org() + self.assertEqual(org1.name, "test org") + self.assertEqual(org1.description, "") + path = reverse("users:organization_detail", args=(org1.pk,)) + data = { + "name": "test org change", + "is_active": False, + "slug": "test-org-change", + "description": "testing PUT", + "email": "testorg@test.com", + "url": "", + } + with self.assertNumQueries(8): + r = self.client.put(path, data, content_type="application/json") + self.assertEqual(r.status_code, 200) + self.assertEqual(r.data["name"], "test org change") + self.assertEqual(r.data["description"], "testing PUT") diff --git a/openwisp_users/tests/test_models.py b/openwisp_users/tests/test_models.py index 93705652f..3471ce11a 100644 --- a/openwisp_users/tests/test_models.py +++ b/openwisp_users/tests/test_models.py @@ -238,16 +238,6 @@ def test_invalidate_cache_org_user_user_changed(self): self.assertEqual(user1.is_member(org), False) self.assertEqual(user2.is_member(org), True) - def test_invalidate_cache_org_status_changed(self): - org = self._create_org(name="testorg1") - user1 = self._create_user(username="testuser1", email="user1@test.com") - self._create_org_user(user=user1, organization=org) - self.assertEqual(user1.is_member(org), True) - org.is_active = False - org.full_clean() - org.save() - self.assertEqual(user1.is_member(org), False) - def test_organizations_managed(self): user = self._create_user(username="organizations_pk") self.assertEqual(user.organizations_managed, []) @@ -1361,6 +1351,38 @@ def test_expiration_reminder_email_recipient_selection(self): class TestOrganizationSignalsTransaction(TestOrganizationMixin, TransactionTestCase): + def test_organization_signal_uses_transition_snapshot_and_retries_after_rollback( + self, + ): + org = self._create_org(name="org-transition-snapshot") + with ( + catch_signal(organization_disabled) as disabled_handler, + catch_signal(organization_enabled) as enabled_handler, + ): + with transaction.atomic(): + org.is_active = False + org.save() + org.is_active = True + org.save() + self.assertEqual( + disabled_handler.call_args.kwargs["instance"].is_active, False + ) + self.assertEqual( + enabled_handler.call_args.kwargs["instance"].is_active, True + ) + disabled_handler.reset_mock() + enabled_handler.reset_mock() + try: + with transaction.atomic(): + org.is_active = False + org.save() + raise RuntimeError + except RuntimeError: + pass + org.is_active = False + org.save() + disabled_handler.assert_called() + def test_organization_disabled_signal(self): org = self._create_org(name="org-to-disable") with ( @@ -1455,3 +1477,13 @@ def test_organization_active_state_signal_not_sent_on_creation(self): self._create_org(name="new-org", is_active=False) disabled_handler.assert_not_called() enabled_handler.assert_not_called() + + def test_invalidate_cache_org_status_changed(self): + org = self._create_org(name="testorg1") + user1 = self._create_user(username="testuser1", email="user1@test.com") + self._create_org_user(user=user1, organization=org) + self.assertEqual(user1.is_member(org), True) + org.is_active = False + org.full_clean() + org.save() + self.assertEqual(user1.is_member(org), False) diff --git a/tests/testapp/tests/test_filter_classes.py b/tests/testapp/tests/test_filter_classes.py index 5d5698659..da754834c 100644 --- a/tests/testapp/tests/test_filter_classes.py +++ b/tests/testapp/tests/test_filter_classes.py @@ -309,6 +309,30 @@ def test_post_book_nested_shelf(self): self.assertEqual(Shelf.objects.count(), 3) self.assertEqual(Book.objects.count(), 3) + def test_post_book_nested_shelf_rejects_disabled_organization(self): + org = self._get_org("org_a") + disabled_org = self._create_org(name="disabled-org", is_active=False) + administrator = self._create_administrator() + self._create_org_user(user=administrator, is_admin=True, organization=org) + token = self._obtain_auth_token(administrator) + response = self.client.post( + reverse("test_book_nested_shelf"), + { + "shelf": { + "name": "disabled-shelf", + "organization": disabled_org.pk, + }, + "name": "disabled-book", + "author": "test-author", + "organization": org.pk, + }, + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + self.assertEqual(response.status_code, 400) + self.assertEqual(Shelf.objects.filter(name="disabled-shelf").exists(), False) + self.assertEqual(Book.objects.filter(name="disabled-book").exists(), False) + def test_shelf_with_read_only_org_field(self): org1 = self._create_org(name="org1") operator = self._get_operator() diff --git a/tests/testapp/tests/test_multitenancy.py b/tests/testapp/tests/test_multitenancy.py index f3fc9e457..22dff4cf5 100644 --- a/tests/testapp/tests/test_multitenancy.py +++ b/tests/testapp/tests/test_multitenancy.py @@ -1,6 +1,7 @@ from django.contrib import admin from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission +from django.contrib.messages.storage.cookie import CookieStorage from django.test import RequestFactory, TestCase from django.urls import reverse @@ -115,12 +116,54 @@ def rename_selected(self, request, queryset): {"action": "rename_selected", "_selected_action": [str(shelf.pk)]}, ) request.user = self._get_admin() + request._messages = CookieStorage(request) model_admin = ShelfActionAdmin(Shelf, admin.site) self.assertTrue(model_admin.has_delete_permission(request, shelf)) model_admin.response_action(request, Shelf.objects.filter(pk=shelf.pk)) shelf.refresh_from_db() self.assertEqual(shelf.name, "action-guard-shelf") + class ShelfActionExemptionAdmin(ShelfActionAdmin): + disabled_organization_action_exclusions = ("rename_selected",) + + request = RequestFactory().post( + "/", + {"action": "rename_selected", "_selected_action": [str(shelf.pk)]}, + ) + request.user = self._get_admin() + model_admin = ShelfActionExemptionAdmin(Shelf, admin.site) + model_admin.response_action(request, Shelf.objects.filter(pk=shelf.pk)) + shelf.refresh_from_db() + self.assertEqual(shelf.name, "renamed-shelf") + + def test_disabled_organization_guard_uses_custom_organization_resolver(self): + class LibraryActionAdmin(MultitenantAdminMixin, admin.ModelAdmin): + actions = ["rename_selected"] + + def get_object_organization(self, obj): + return obj.book.organization + + @admin.action(permissions=["change"]) + def rename_selected(self, request, queryset): + queryset.update(name="renamed-library") + + org = self._get_org() + book = self._create_book(name="resolver-book", organization=org) + library = self._create_library(name="resolver-library", book=book) + org.is_active = False + org.save() + request = RequestFactory().post( + "/", + {"action": "rename_selected", "_selected_action": [str(library.pk)]}, + ) + request.user = self._get_admin() + request._messages = CookieStorage(request) + model_admin = LibraryActionAdmin(Library, admin.site) + self.assertEqual(model_admin.has_change_permission(request, library), False) + model_admin.response_action(request, Library.objects.filter(pk=library.pk)) + library.refresh_from_db() + self.assertEqual(library.name, "resolver-library") + def test_disabled_org_admin_crud_org_admin_loses_access(self): org = self._create_org(name="admin-mixin-org-oa") shelf = self._create_shelf(name="admin-mixin-shelf-oa", organization=org) diff --git a/tests/testapp/tests/test_selenium.py b/tests/testapp/tests/test_selenium.py index cf9b4860b..14f3f350d 100644 --- a/tests/testapp/tests/test_selenium.py +++ b/tests/testapp/tests/test_selenium.py @@ -16,6 +16,7 @@ from .mixins import TestMultitenancyMixin Organization = load_model("openwisp_users", "Organization") +OrganizationUser = load_model("openwisp_users", "OrganizationUser") User = get_user_model() @@ -169,19 +170,32 @@ def test_user_add_form_does_not_hang(self): def test_dynamic_organization_inline_normalizes_shared_value_on_submit(self): path = reverse(f"admin:{User._meta.app_label}_user_add") + username = "shared-inline-user" self.login(username=self.admin_username, password=self.admin_password) self.open(path) - value = self.web_driver.execute_script(""" - const form = document.querySelector("form"); - const field = document.createElement("select"); - field.dataset.fieldName = "organization"; - field.append(new Option("Shared systemwide", "null", true, true)); - form.append(field); - form.addEventListener( - "submit", event => event.preventDefault(), {once: true} - ); - form.dispatchEvent(new Event("submit", {bubbles: true, cancelable: true})); - return field.value; - """) - self.assertEqual(value, "") + self.find_element( + By.CSS_SELECTOR, "#openwisp_users_organizationuser-group .add-row a" + ).click() + org_field = WebDriverWait(self.web_driver, 5).until( + EC.presence_of_element_located( + (By.CSS_SELECTOR, "#openwisp_users_organizationuser-0-organization") + ) + ) + self.web_driver.execute_script( + "arguments[0].value = 'null'; " + "django.jQuery(arguments[0]).trigger('change');", + org_field, + ) + self.find_element(By.ID, "id_username").send_keys(username) + self.find_element(By.ID, "id_password1").send_keys("testpassword") + self.find_element(By.ID, "id_password2").send_keys("testpassword") + self.find_element(By.CSS_SELECTOR, "input[name='_save']").click() + changelist_url = reverse(f"admin:{User._meta.app_label}_user_changelist") + WebDriverWait(self.web_driver, 5).until( + EC.url_to_be(f"{self.live_server_url}{changelist_url}") + ) + self.assertEqual( + OrganizationUser.objects.get(user__username=username).organization_id, + None, + ) self.logout() From 03ab8b24614fbb30ea4a157355d47363fddefcf8 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Mon, 17 Aug 2026 23:45:22 +0530 Subject: [PATCH 24/34] [fix] Fixed selenium tests --- tests/testapp/tests/test_selenium.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/tests/testapp/tests/test_selenium.py b/tests/testapp/tests/test_selenium.py index 14f3f350d..247090356 100644 --- a/tests/testapp/tests/test_selenium.py +++ b/tests/testapp/tests/test_selenium.py @@ -169,16 +169,22 @@ def test_user_add_form_does_not_hang(self): self.logout() def test_dynamic_organization_inline_normalizes_shared_value_on_submit(self): + # OrganizationUser.organization is a required field, so selecting + # "Shared systemwide (no organization)" on an inline row must be + # rejected with a validation error rather than saved as None. path = reverse(f"admin:{User._meta.app_label}_user_add") username = "shared-inline-user" self.login(username=self.admin_username, password=self.admin_password) self.open(path) + self.find_element( + By.CSS_SELECTOR, "#openwisp_users_organizationuser-0 .inline-deletelink" + ).click() self.find_element( By.CSS_SELECTOR, "#openwisp_users_organizationuser-group .add-row a" ).click() org_field = WebDriverWait(self.web_driver, 5).until( EC.presence_of_element_located( - (By.CSS_SELECTOR, "#openwisp_users_organizationuser-0-organization") + (By.CSS_SELECTOR, "#id_openwisp_users_organizationuser-0-organization") ) ) self.web_driver.execute_script( @@ -187,15 +193,17 @@ def test_dynamic_organization_inline_normalizes_shared_value_on_submit(self): org_field, ) self.find_element(By.ID, "id_username").send_keys(username) + self.find_element(By.ID, "id_email").send_keys("test@openwisp.org") self.find_element(By.ID, "id_password1").send_keys("testpassword") self.find_element(By.ID, "id_password2").send_keys("testpassword") self.find_element(By.CSS_SELECTOR, "input[name='_save']").click() - changelist_url = reverse(f"admin:{User._meta.app_label}_user_changelist") - WebDriverWait(self.web_driver, 5).until( - EC.url_to_be(f"{self.live_server_url}{changelist_url}") + error = WebDriverWait(self.web_driver, 5).until( + EC.presence_of_element_located( + (By.CSS_SELECTOR, "#openwisp_users_organizationuser-0 .errorlist") + ) ) - self.assertEqual( - OrganizationUser.objects.get(user__username=username).organization_id, - None, + self.assertIn("This field is required", error.text) + self.assertFalse( + OrganizationUser.objects.filter(user__username=username).exists() ) self.logout() From ec88b546bd2c1eacd89351581ff5a3ef58ed809c Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Tue, 18 Aug 2026 00:24:55 +0530 Subject: [PATCH 25/34] [chores] Fixed selenium test --- tests/testapp/tests/test_selenium.py | 30 ++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/tests/testapp/tests/test_selenium.py b/tests/testapp/tests/test_selenium.py index 247090356..124c2192b 100644 --- a/tests/testapp/tests/test_selenium.py +++ b/tests/testapp/tests/test_selenium.py @@ -174,23 +174,33 @@ def test_dynamic_organization_inline_normalizes_shared_value_on_submit(self): # rejected with a validation error rather than saved as None. path = reverse(f"admin:{User._meta.app_label}_user_add") username = "shared-inline-user" + app_label = OrganizationUser._meta.app_label + organization = self._create_org(name="inline-organization") self.login(username=self.admin_username, password=self.admin_password) self.open(path) self.find_element( - By.CSS_SELECTOR, "#openwisp_users_organizationuser-0 .inline-deletelink" + By.CSS_SELECTOR, f"#{app_label}_organizationuser-group .add-row a" ).click() - self.find_element( - By.CSS_SELECTOR, "#openwisp_users_organizationuser-group .add-row a" - ).click() - org_field = WebDriverWait(self.web_driver, 5).until( + static_org_field = self.find_element( + By.ID, f"id_{app_label}_organizationuser-0-organization" + ) + dynamic_org_field = WebDriverWait(self.web_driver, 5).until( EC.presence_of_element_located( - (By.CSS_SELECTOR, "#id_openwisp_users_organizationuser-0-organization") + (By.ID, f"id_{app_label}_organizationuser-1-organization") ) ) self.web_driver.execute_script( - "arguments[0].value = 'null'; " - "django.jQuery(arguments[0]).trigger('change');", - org_field, + "var organization = new Option(arguments[1], arguments[2], true, true); " + "django.jQuery(arguments[0]).append(organization).trigger('change');", + static_org_field, + organization.name, + str(organization.pk), + ) + self.web_driver.execute_script( + "var shared = new Option(" + "'Shared systemwide (no organization)', 'null', true, true); " + "django.jQuery(arguments[0]).append(shared).trigger('change');", + dynamic_org_field, ) self.find_element(By.ID, "id_username").send_keys(username) self.find_element(By.ID, "id_email").send_keys("test@openwisp.org") @@ -199,7 +209,7 @@ def test_dynamic_organization_inline_normalizes_shared_value_on_submit(self): self.find_element(By.CSS_SELECTOR, "input[name='_save']").click() error = WebDriverWait(self.web_driver, 5).until( EC.presence_of_element_located( - (By.CSS_SELECTOR, "#openwisp_users_organizationuser-0 .errorlist") + (By.CSS_SELECTOR, f"#{app_label}_organizationuser-1 .errorlist") ) ) self.assertIn("This field is required", error.text) From 1a8f4d18e65ac855f07b58da7be2ab9a39f9471d Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Tue, 18 Aug 2026 01:36:01 +0530 Subject: [PATCH 26/34] [docs] Updated heading and anchor tag --- .github/workflows/ci.yml | 3 +++ docs/developer/admin-utils.rst | 2 +- .../developer/django-rest-framework-utils.rst | 4 ++-- docs/user/basic-concepts.rst | 23 +++++++++---------- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 049a8bf99..e56316b7d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,6 +75,9 @@ jobs: pip install -U -r requirements-test.txt sudo npm install -g prettier pip install -e .[rest] + pip install --upgrade --force-reinstall --no-deps --no-cache-dir https://github.com/openwisp/openwisp-users/tarball/issues/522-disabled-org + pip install --upgrade --force-reinstall --no-deps --no-cache-dir https://github.com/openwisp/openwisp-controller/tarball/issues/1393-disabled-org + pip install --upgrade --force-reinstall --no-deps --no-cache-dir https://github.com/openwisp/openwisp-monitoring/tarball/issues/811-disabled-org pip install -U ${{ matrix.django-version }} - name: QA checks diff --git a/docs/developer/admin-utils.rst b/docs/developer/admin-utils.rst index b2e61a731..03e61a27a 100644 --- a/docs/developer/admin-utils.rst +++ b/docs/developer/admin-utils.rst @@ -35,7 +35,7 @@ Disabled Organization Write Protection ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ``MultitenantAdminMixin`` also blocks changes to any object belonging to a -:ref:`disabled organization `, while still +:ref:`disabled organization `, while still allowing it to be viewed and deleted. This also applies to superusers. For models whose organization is reached through a parent, the mixin follows ``multitenant_parent`` to protect those objects too. diff --git a/docs/developer/django-rest-framework-utils.rst b/docs/developer/django-rest-framework-utils.rst index 02c6f5afc..46f030401 100644 --- a/docs/developer/django-rest-framework-utils.rst +++ b/docs/developer/django-rest-framework-utils.rst @@ -138,7 +138,7 @@ Standard users will not be able to view or list shared objects. ``openwisp_users.api.permissions.DisabledOrgReadOnly``. This object-level permission class blocks updates to objects belonging to -a :ref:`disabled organization `. ``GET``, +a :ref:`disabled organization `. ``GET``, ``HEAD``, ``OPTIONS`` and ``DELETE`` remain allowed. The object's organization is resolved through the view's @@ -317,7 +317,7 @@ These serializers do not allow non-superusers to create shared objects. .. _multi_tenant_serializers_disabled_org: These serializers also exclude :ref:`disabled organizations -` from the ``organization`` field for all +` from the ``organization`` field for all users, including superusers. Submitting a disabled organization's primary key returns a validation error. diff --git a/docs/user/basic-concepts.rst b/docs/user/basic-concepts.rst index 77e430436..995938e38 100644 --- a/docs/user/basic-concepts.rst +++ b/docs/user/basic-concepts.rst @@ -149,18 +149,18 @@ instance of the platform. `django-organizations `_ third-party app. -.. _disabling_an_organization: +.. _users_disabled_organization: -Disabling an Organization -------------------------- +Disabled Organization +--------------------- -Superusers and managers of the organization can disable it by unchecking -its **Is active** flag on the "Change organization" page or via the REST -API (subject to the usual permission requirements for editing an -organization). +An organization is disabled when its **Is active** flag is unchecked on +the "Change organization" page or through the REST API. Superusers and +organization managers can disable an organization, subject to the usual +permission requirements for editing it. -Disabling an organization does not delete its users, memberships, or -related objects. Superusers can still read and delete that data, but: +A disabled organization retains its users, memberships, and related +objects. Superusers can still read and delete that data, but: - **No new object can be created for a disabled organization**, and **existing objects belonging to it cannot be modified**. For the @@ -178,9 +178,8 @@ related objects. Superusers can still read and delete that data, but: .. note:: - In the REST API, an update to an object in a disabled organization - returns HTTP 400 or 403, depending on the endpoint, with an error - message. + In the REST API, updating an object in a disabled organization returns + HTTP 400 or 403, depending on the endpoint, with an error message. .. note:: From e02cde32c9b3a3ae596efb29156697b61bf52a6e Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Tue, 18 Aug 2026 12:32:08 +0530 Subject: [PATCH 27/34] [docs] Added disabled organization points in AGENTS.md --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index eec2ab709..effb884ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,6 +74,8 @@ If instructions conflict, repository config and CI workflows win first, official - Cached lookups must check permission and organization scope on every request. Changed endpoints need cross-organization regression tests. - If you change swapped-model behavior, tenant isolation, auth flows, or admin/API permissions, cover both package-level and integration tests. - Changes to HTTP REST API endpoints or Django REST Framework serializers must include tests for permissions, input validation, filtering or pagination when supported, and organization or tenant boundaries where applicable. +- Objects belonging to a disabled organization must be readable and deletable; creation and updates must be blocked across all relevant write paths. +- New organization-scoped models and operations, including direct or indirect organization relationships, must follow this contract and include explicit tests. Block non-CRUD operations that change state or perform writes on behalf of the object. ## Troubleshooting From 72a6878152b7851a5d4346624696a50761ca14cf Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Tue, 18 Aug 2026 16:53:15 +0530 Subject: [PATCH 28/34] [fix] Made requested changes --- openwisp_users/tests/test_api/test_api.py | 52 ++++--- openwisp_users/tests/test_models.py | 129 +++++++----------- .../testapp/tests/test_permission_classes.py | 67 --------- 3 files changed, 74 insertions(+), 174 deletions(-) diff --git a/openwisp_users/tests/test_api/test_api.py b/openwisp_users/tests/test_api/test_api.py index eb022c8f8..c9c5a7f05 100644 --- a/openwisp_users/tests/test_api/test_api.py +++ b/openwisp_users/tests/test_api/test_api.py @@ -101,39 +101,37 @@ def test_organization_patch_api(self): self.assertEqual(r.status_code, 200) self.assertEqual(r.data["name"], "test org change") - def test_patch_disabled_organization_field_without_reenabling_api(self): + def test_patch_disabled_organization_api(self): org1 = self._get_org() org1.is_active = False org1.save() - path = reverse("users:organization_detail", args=(org1.pk,)) - data = {"name": "test org change"} - r = self.client.patch(path, data, content_type="application/json") - self.assertEqual(r.status_code, 400) - org1.refresh_from_db() - self.assertEqual(org1.name, "test org") - - def test_patch_disabled_organization_reenable_api(self): - org1 = self._get_org() - org1.is_active = False - org1.save() - path = reverse("users:organization_detail", args=(org1.pk,)) - data = {"is_active": True} - r = self.client.patch(path, data, content_type="application/json") - self.assertEqual(r.status_code, 200) - org1.refresh_from_db() - self.assertTrue(org1.is_active) + with self.subTest("field update without re-enabling is rejected"): + path = reverse("users:organization_detail", args=(org1.pk,)) + data = {"name": "test org change"} + resposne = self.client.patch(path, data, content_type="application/json") + self.assertEqual(resposne.status_code, 400) + org1.refresh_from_db() + self.assertEqual(org1.name, "test org") + + with self.subTest("re-enable only is allowed"): + org1 = self._get_org() + path = reverse("users:organization_detail", args=(org1.pk,)) + data = {"is_active": True} + resposne = self.client.patch(path, data, content_type="application/json") + self.assertEqual(resposne.status_code, 200) + org1.refresh_from_db() + self.assertTrue(org1.is_active) - def test_reenable_disabled_organization_with_field_edit_api(self): - org1 = self._get_org() org1.is_active = False org1.save() - path = reverse("users:organization_detail", args=(org1.pk,)) - data = {"is_active": True, "name": "renamed while disabled"} - r = self.client.patch(path, data, content_type="application/json") - self.assertEqual(r.status_code, 400) - org1.refresh_from_db() - self.assertEqual(org1.is_active, False) - self.assertEqual(org1.name, "test org") + with self.subTest("re-enable with field edit is rejected"): + path = reverse("users:organization_detail", args=(org1.pk,)) + data = {"is_active": True, "name": "renamed while disabled"} + r = self.client.patch(path, data, content_type="application/json") + self.assertEqual(r.status_code, 400) + org1.refresh_from_db() + self.assertEqual(org1.is_active, False) + self.assertEqual(org1.name, "test org") def test_reenable_disabled_organization_via_put_api(self): org1 = self._get_org() diff --git a/openwisp_users/tests/test_models.py b/openwisp_users/tests/test_models.py index 3471ce11a..a543b1424 100644 --- a/openwisp_users/tests/test_models.py +++ b/openwisp_users/tests/test_models.py @@ -1351,86 +1351,64 @@ def test_expiration_reminder_email_recipient_selection(self): class TestOrganizationSignalsTransaction(TestOrganizationMixin, TransactionTestCase): - def test_organization_signal_uses_transition_snapshot_and_retries_after_rollback( - self, - ): - org = self._create_org(name="org-transition-snapshot") - with ( - catch_signal(organization_disabled) as disabled_handler, - catch_signal(organization_enabled) as enabled_handler, - ): - with transaction.atomic(): + def test_organization_active_state_signals(self): + with self.subTest("disabled"): + org = self._create_org(name="org-to-disable") + with ( + catch_signal(organization_disabled) as disabled_handler, + catch_signal(organization_enabled) as enabled_handler, + ): org.is_active = False org.save() + disabled_handler.assert_called_once_with( + signal=organization_disabled, sender=Organization, instance=org + ) + enabled_handler.assert_not_called() + + with self.subTest("enabled"): + org = self._create_org(name="org-to-enable", is_active=False) + with ( + catch_signal(organization_disabled) as disabled_handler, + catch_signal(organization_enabled) as enabled_handler, + ): org.is_active = True org.save() - self.assertEqual( - disabled_handler.call_args.kwargs["instance"].is_active, False - ) - self.assertEqual( - enabled_handler.call_args.kwargs["instance"].is_active, True + enabled_handler.assert_called_once_with( + signal=organization_enabled, sender=Organization, instance=org ) - disabled_handler.reset_mock() - enabled_handler.reset_mock() - try: - with transaction.atomic(): - org.is_active = False - org.save() - raise RuntimeError - except RuntimeError: - pass - org.is_active = False - org.save() - disabled_handler.assert_called() - - def test_organization_disabled_signal(self): - org = self._create_org(name="org-to-disable") - with ( - catch_signal(organization_disabled) as disabled_handler, - catch_signal(organization_enabled) as enabled_handler, - ): - org.is_active = False - org.save() - disabled_handler.assert_called_once_with( - signal=organization_disabled, sender=Organization, instance=org - ) - enabled_handler.assert_not_called() + disabled_handler.assert_not_called() - def test_organization_enabled_signal(self): - org = self._create_org(name="org-to-enable", is_active=False) - with ( - catch_signal(organization_disabled) as disabled_handler, - catch_signal(organization_enabled) as enabled_handler, - ): - org.is_active = True - org.save() - enabled_handler.assert_called_once_with( - signal=organization_enabled, sender=Organization, instance=org - ) - disabled_handler.assert_not_called() + with self.subTest("unrelated field change"): + org = self._create_org(name="org-unrelated-change") + with ( + catch_signal(organization_disabled) as disabled_handler, + catch_signal(organization_enabled) as enabled_handler, + ): + org.description = "updated description" + org.save() + disabled_handler.assert_not_called() + enabled_handler.assert_not_called() - def test_organization_active_state_signal_not_sent_on_unrelated_change(self): - org = self._create_org(name="org-unrelated-change") - with ( - catch_signal(organization_disabled) as disabled_handler, - catch_signal(organization_enabled) as enabled_handler, - ): - org.description = "updated description" - org.save() - disabled_handler.assert_not_called() - enabled_handler.assert_not_called() + with self.subTest("update_fields respected"): + org = self._create_org(name="org-update-fields") + org.is_active = False + org.name = "renamed-org" + with catch_signal(organization_disabled) as disabled_handler: + org.save(update_fields={"name"}) + disabled_handler.assert_not_called() + org.save(update_fields={"is_active"}) + disabled_handler.assert_called_once_with( + signal=organization_disabled, sender=Organization, instance=org + ) - def test_organization_active_state_signal_respects_update_fields(self): - org = self._create_org(name="org-update-fields") - org.is_active = False - org.name = "renamed-org" - with catch_signal(organization_disabled) as disabled_handler: - org.save(update_fields={"name"}) + with self.subTest("not sent on creation"): + with ( + catch_signal(organization_disabled) as disabled_handler, + catch_signal(organization_enabled) as enabled_handler, + ): + self._create_org(name="new-org", is_active=False) disabled_handler.assert_not_called() - org.save(update_fields={"is_active"}) - disabled_handler.assert_called_once_with( - signal=organization_disabled, sender=Organization, instance=org - ) + enabled_handler.assert_not_called() def test_organization_signal_transaction_state(self): with self.subTest("callbacks receive the state of each transition"): @@ -1469,15 +1447,6 @@ def test_organization_signal_transaction_state(self): signal=organization_disabled, sender=Organization, instance=org ) - def test_organization_active_state_signal_not_sent_on_creation(self): - with ( - catch_signal(organization_disabled) as disabled_handler, - catch_signal(organization_enabled) as enabled_handler, - ): - self._create_org(name="new-org", is_active=False) - disabled_handler.assert_not_called() - enabled_handler.assert_not_called() - def test_invalidate_cache_org_status_changed(self): org = self._create_org(name="testorg1") user1 = self._create_user(username="testuser1", email="user1@test.com") diff --git a/tests/testapp/tests/test_permission_classes.py b/tests/testapp/tests/test_permission_classes.py index 519f4ac72..41a91e35a 100644 --- a/tests/testapp/tests/test_permission_classes.py +++ b/tests/testapp/tests/test_permission_classes.py @@ -461,45 +461,6 @@ def get_queryset(self): response = self.client.delete(detail_url, **auth) self.assertEqual(response.status_code, 204) - def test_disabled_org_api_crud_superuser_only(self): - org = self._create_org(name="api-mixin-org") - template = self._create_template(name="t-super", organization=org) - org.is_active = False - org.save() - self._test_disabled_org_api_crud( - template, - detail_url=reverse("test_template_detail", args=[template.pk]), - list_url=reverse("test_template_list"), - create_payload={"name": "t-super-new", "organization": str(org.pk)}, - update_payload={"name": "t-super-upd"}, - roles=("superuser",), - ) - - def test_disabled_org_api_crud_org_admin_loses_access(self): - org = self._create_org(name="api-mixin-org-oa") - template = self._create_template(name="t-oa", organization=org) - org.is_active = False - org.save() - self._test_disabled_org_api_crud( - template, - detail_url=reverse("test_template_detail", args=[template.pk]), - list_url=reverse("test_template_list"), - create_payload={"name": "t-oa-new", "organization": str(org.pk)}, - update_payload={"name": "t-oa-upd"}, - roles=("org_admin",), - org_admin_expected={ - "list": {"status": 200, "object_present": False}, - "retrieve": {"status": 404}, - "create": { - "status": 400, - "error_field": "organization", - "error_contains": "does not exist or is disabled", - }, - "update": {"status": 404, "unchanged": True}, - "delete": {"status": 404, "exists_after": True}, - }, - ) - def test_disabled_org_api_crud_both_roles(self): org = self._create_org(name="api-mixin-org-both") template = self._create_template(name="t-both", organization=org) @@ -538,34 +499,6 @@ def test_disabled_org_api_crud_session_auth(self): auth_mechanism="session", ) - def test_disabled_org_api_crud_bare_protected_mixin(self): - org = self._create_org(name="api-mixin-org-bare") - template = self._create_template(name="t-bare", organization=org) - org.is_active = False - org.save() - self._test_disabled_org_api_crud( - template, - detail_url=reverse("test_protected_template_detail", args=[template.pk]), - roles=("superuser",), - operations=("retrieve", "update"), - update_payload={"name": "t-bare-upd"}, - ) - - def test_disabled_org_api_crud_operations_subset(self): - org = self._create_org(name="api-mixin-org-subset") - template = self._create_template(name="t-subset", organization=org) - org.is_active = False - org.save() - self._test_disabled_org_api_crud( - template, - detail_url=reverse("test_template_detail", args=[template.pk]), - roles=("superuser",), - operations=("retrieve",), - ) - self.assertEqual( - self.template_model.objects.filter(pk=template.pk).exists(), True - ) - def test_disabled_org_api_crud_opt_out_override(self): org = self._create_org(name="api-mixin-org-optout") template = self._create_template(name="t-optout", organization=org) From e77da023e3fb67920bc3cacefd31208fed74e523 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Wed, 19 Aug 2026 23:03:58 +0530 Subject: [PATCH 29/34] [fix] Filter out objects from the disabled organization in AutocompleteView --- openwisp_users/multitenancy.py | 6 +++++ openwisp_users/tests/utils.py | 33 ++++++++++++++++++++++-- tests/testapp/admin.py | 1 + tests/testapp/tests/test_multitenancy.py | 12 +++++++++ 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/openwisp_users/multitenancy.py b/openwisp_users/multitenancy.py index 2ab50d97d..871ef053d 100644 --- a/openwisp_users/multitenancy.py +++ b/openwisp_users/multitenancy.py @@ -52,6 +52,12 @@ def get_queryset(self, request): if self.model == User: return self.multitenant_behaviour_for_user_admin(request) if user.is_superuser: + # Autocomplete requests exclude objects associated with disabled organizations. + if "field_name" in request.GET and hasattr(self.model, "organization"): + active_or_shared = Q(organization__is_active=True) | Q( + organization=None + ) + return qs.filter(active_or_shared) return qs if hasattr(self.model, "organization"): return qs.filter( diff --git a/openwisp_users/tests/utils.py b/openwisp_users/tests/utils.py index 19393df45..16ecc5c29 100644 --- a/openwisp_users/tests/utils.py +++ b/openwisp_users/tests/utils.py @@ -197,6 +197,9 @@ class TestDisabledOrgAdminMixin(TestDisabledOrgMixin): "view": {"status": 200}, "change": {"status": 403, "unchanged": True}, "delete": {"status": 200, "exists_after": False}, + # The organization field always excludes disabled organizations + # on add, regardless of role or write-protection opt-out. + "add": {"status": 200, "created": False}, }, "org_admin": { # The disabled object is outside the manager's queryset, so the @@ -204,6 +207,10 @@ class TestDisabledOrgAdminMixin(TestDisabledOrgMixin): "view": {"status": 302}, "change": {"status": 200, "unchanged": True}, "delete": {"status": 200, "exists_after": True}, + # organizations_managed excludes disabled organizations, so an + # org_admin whose only managed org is disabled has no add + # permission at all. + "add": {"status": 403, "created": False}, }, } @@ -215,7 +222,13 @@ def _get_disabled_org_admin_urls(self, obj, admin_site="admin"): delete_url = reverse( f"{admin_site}:{meta.app_label}_{meta.model_name}_delete", args=[obj.pk] ) - return {"view": change_url, "change": change_url, "delete": delete_url} + add_url = reverse(f"{admin_site}:{meta.app_label}_{meta.model_name}_add") + return { + "view": change_url, + "change": change_url, + "delete": delete_url, + "add": add_url, + } def _test_disabled_org_admin_view(self, url, status=200): response = self.client.get(url) @@ -245,6 +258,14 @@ def _test_disabled_org_admin_delete( self.assertEqual(response.status_code, status) self.assertEqual(model.objects.filter(pk=pk).exists(), exists_after) + def _test_disabled_org_admin_add( + self, url, create_data, model, status=200, created=False + ): + count_before = model.objects.count() + response = self.client.post(url, create_data, follow=True) + self.assertEqual(response.status_code, status) + self.assertEqual(model.objects.count() > count_before, created) + def _test_disabled_org_admin_org_field_excludes_disabled( self, url, @@ -269,13 +290,16 @@ def _test_disabled_org_admin_crud( obj, change_data, roles=("org_admin", "superuser"), - operations=("view", "change", "delete"), + operations=("view", "change", "delete", "add"), organization=None, org_admin_expected=None, superuser_expected=None, unchanged_field="name", + create_data=None, ): """Run shared checks for direct or parent-linked organizations.""" + if create_data is None: + operations = tuple(op for op in operations if op != "add") organization = organization or getattr(obj, "organization", None) urls = self._get_disabled_org_admin_urls(obj) specs = { @@ -308,6 +332,10 @@ def _test_disabled_org_admin_crud( self._test_disabled_org_admin_delete( urls["delete"], type(obj), obj.pk, **spec ) + elif operation == "add": + self._test_disabled_org_admin_add( + urls["add"], create_data, type(obj), **spec + ) else: raise ValueError(f"Unknown operation: {operation!r}") self.client.logout() @@ -351,6 +379,7 @@ class TestMultitenantAdminMixin(TestDisabledOrgAdminMixin): def setUp(self): admin = self._create_admin(password="tester") admin.organizations_dict # force caching + super().setUp() def _login(self, username="admin", password="tester"): self.client.login(username=username, password=password) diff --git a/tests/testapp/admin.py b/tests/testapp/admin.py index 0b776c3cf..7b5c903cd 100644 --- a/tests/testapp/admin.py +++ b/tests/testapp/admin.py @@ -43,6 +43,7 @@ class BookAdmin(BaseAdmin): ShelfFilter, ] fields = ["name", "author", "organization", "shelf", "created", "modified"] + autocomplete_fields = ["shelf"] multitenant_shared_relations = ["shelf"] def change_view(self, request, object_id, form_url="", extra_context=None): diff --git a/tests/testapp/tests/test_multitenancy.py b/tests/testapp/tests/test_multitenancy.py index 22dff4cf5..c9fabf0f8 100644 --- a/tests/testapp/tests/test_multitenancy.py +++ b/tests/testapp/tests/test_multitenancy.py @@ -88,6 +88,17 @@ def test_book_shelf_fk_queryset(self): superuser_hidden=[data["s3_inactive"].name], ) + def test_book_shelf_fk_autocomplete_view(self): + data = self._create_multitenancy_test_env() + self._test_multitenant_admin( + url=self._get_autocomplete_view_path("testapp", "book", "shelf"), + visible=[data["s1"].name], + hidden=[data["s2"].name], + administrator=True, + # Keep disabled organizations hidden even for superusers. + superuser_hidden=[data["s3_inactive"].name], + ) + def test_shelf_disabled_organization_admin_guard(self): org = self._get_org() shelf = self._create_shelf(name="disable-guard-shelf", organization=org) @@ -183,6 +194,7 @@ def test_disabled_org_admin_crud_both_roles(self): self._test_disabled_org_admin_crud( shelf, change_data={"name": "renamed", "organization": str(org.pk)}, + create_data={"name": "new-shelf", "organization": str(org.pk)}, ) def test_disabled_org_admin_crud_operations_subset(self): From e7674e3280726fa5f63175ad3ede0c6e8486e2c3 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Wed, 19 Aug 2026 23:11:07 +0530 Subject: [PATCH 30/34] [chores] Updated wording for handling disabled org in AGENTS.md --- AGENTS.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index effb884ba..37bc6c784 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,8 +74,7 @@ If instructions conflict, repository config and CI workflows win first, official - Cached lookups must check permission and organization scope on every request. Changed endpoints need cross-organization regression tests. - If you change swapped-model behavior, tenant isolation, auth flows, or admin/API permissions, cover both package-level and integration tests. - Changes to HTTP REST API endpoints or Django REST Framework serializers must include tests for permissions, input validation, filtering or pagination when supported, and organization or tenant boundaries where applicable. -- Objects belonging to a disabled organization must be readable and deletable; creation and updates must be blocked across all relevant write paths. -- New organization-scoped models and operations, including direct or indirect organization relationships, must follow this contract and include explicit tests. Block non-CRUD operations that change state or perform writes on behalf of the object. +- Objects belonging to a disabled organization must be readable and deletable; creation and updates must be blocked across all relevant write paths. This applies to objects with either a direct or chained/nested relationship to the organization. No other operations should be permitted, except for ordinary cleanup operations. ## Troubleshooting From 71d34bf5db701949ba71691151fd63fcd1cb1193 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Fri, 21 Aug 2026 01:28:53 +0530 Subject: [PATCH 31/34] [fix] Fixed tests --- openwisp_users/multitenancy.py | 3 ++- tests/testapp/tests/test_multitenancy.py | 21 +++++++++++++-------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/openwisp_users/multitenancy.py b/openwisp_users/multitenancy.py index 871ef053d..c20cc16cc 100644 --- a/openwisp_users/multitenancy.py +++ b/openwisp_users/multitenancy.py @@ -52,7 +52,8 @@ def get_queryset(self, request): if self.model == User: return self.multitenant_behaviour_for_user_admin(request) if user.is_superuser: - # Autocomplete requests exclude objects associated with disabled organizations. + # Autocomplete requests exclude objects associated with + # disabled organizations. if "field_name" in request.GET and hasattr(self.model, "organization"): active_or_shared = Q(organization__is_active=True) | Q( organization=None diff --git a/tests/testapp/tests/test_multitenancy.py b/tests/testapp/tests/test_multitenancy.py index c9fabf0f8..aaab31020 100644 --- a/tests/testapp/tests/test_multitenancy.py +++ b/tests/testapp/tests/test_multitenancy.py @@ -78,15 +78,20 @@ def test_book_queryset(self): def test_book_shelf_fk_queryset(self): data = self._create_multitenancy_test_env() - self._test_multitenant_admin( - url=reverse("admin:testapp_book_add"), - visible=[data["s1"].name], - hidden=[data["s2"].name, data["s3_inactive"].name], - select_widget=True, - administrator=True, - # Keep disabled organizations hidden even for superusers. - superuser_hidden=[data["s3_inactive"].name], + url = reverse("admin:testapp_book_add") + cases = ( + ("administrator", {data["s1"].pk}), + ("admin", {data["s1"].pk, data["s2"].pk}), ) + for username, expected_shelf_pks in cases: + with self.subTest(username=username): + self._login(username=username, password="tester") + response = self.client.get(url) + queryset = response.context["adminform"].form.fields["shelf"].queryset + self.assertEqual( + set(queryset.values_list("pk", flat=True)), expected_shelf_pks + ) + self._logout() def test_book_shelf_fk_autocomplete_view(self): data = self._create_multitenancy_test_env() From e393e9b9ef14d3f7bd454e9de0b07a33d7f7b5cb Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Fri, 21 Aug 2026 16:10:55 +0530 Subject: [PATCH 32/34] [fix] Fixes to UserAdmin inlines --- openwisp_users/admin.py | 53 ++++++++++------ tests/testapp/admin.py | 14 ++++- tests/testapp/migrations/0007_bio.py | 52 ++++++++++++++++ tests/testapp/models.py | 11 ++++ tests/testapp/tests/test_selenium.py | 91 ++++++++++++++++++++++++++++ 5 files changed, 201 insertions(+), 20 deletions(-) create mode 100644 tests/testapp/migrations/0007_bio.py diff --git a/openwisp_users/admin.py b/openwisp_users/admin.py index 6cbfe6ea0..55cfcbbbf 100644 --- a/openwisp_users/admin.py +++ b/openwisp_users/admin.py @@ -16,6 +16,7 @@ from django.contrib.auth.forms import UserCreationForm as BaseUserCreationForm from django.core.exceptions import ValidationError from django.db.models import Q +from django.forms.formsets import DELETION_FIELD_NAME from django.forms.models import BaseInlineFormSet from django.http import HttpResponseRedirect from django.template.response import TemplateResponse @@ -113,31 +114,45 @@ def has_change_permission(self, request, obj=None): return super().has_change_permission(request, obj) -class OrganizationUserInlineFormSet(RequiredInlineFormSet): +class MultitenantReadOnlyInlineFormSet(BaseInlineFormSet): """ - Keep disabled memberships valid on no-op saves while allowing deletion. + Keep rows belonging to a disabled organization valid on no-op saves while + making their editable fields read-only and preserving deletion. """ def add_fields(self, form, index): super().add_fields(form, index) instance = getattr(form, "instance", None) - if ( - instance - and instance.pk - and instance.organization_id - and not instance.organization.is_active - ): - org_field = form.fields.get("organization") - if org_field is not None: - # The formset queryset excludes disabled organizations, - # so the current membership's organization must be added - # back or the disabled field fails validation against it. - org_model = org_field.queryset.model - org_field.queryset = org_field.queryset | org_model.objects.filter( - pk=instance.organization_id - ) - for field in form.fields.values(): - field.disabled = True + if not (instance and instance.pk and instance.organization_id): + return + if instance.organization.is_active: + return + organization_field = form.fields.get("organization") + if organization_field is not None: + # The formset queryset excludes disabled organizations, + # so the current row's value must be added back or the + # disabled field fails validation against it. + organization_model = organization_field.queryset.model + organization_field.queryset = organization_field.queryset | ( + organization_model.objects.filter(pk=instance.organization_id) + ) + pk_name = instance._meta.pk.name + for name, field in form.fields.items(): + # The pk field and the parent-link field must stay enabled: a + # disabled field is never submitted by the browser, and when the + # pk field is missing from POST data BaseModelFormSet treats the + # row as tampered with and silently builds a blank instance + # instead of loading the existing one, which then fails + # validation on unrelated required fields. + if name in (pk_name, self.fk.name, DELETION_FIELD_NAME): + continue + field.disabled = True + + +class OrganizationUserInlineFormSet( + MultitenantReadOnlyInlineFormSet, RequiredInlineFormSet +): + pass class OrganizationUserInline(admin.StackedInline): diff --git a/tests/testapp/admin.py b/tests/testapp/admin.py index 7b5c903cd..8c3349f43 100644 --- a/tests/testapp/admin.py +++ b/tests/testapp/admin.py @@ -1,13 +1,14 @@ from django.contrib import admin from django.utils.translation import gettext_lazy as _ +from openwisp_users.admin import MultitenantReadOnlyInlineFormSet, RequiredInlineFormSet from openwisp_users.multitenancy import ( MultitenantAdminMixin, MultitenantOrgFilter, MultitenantRelatedOrgFilter, ) -from .models import Book, Config, Library, Shelf, Tag, Template +from .models import Bio, Book, Config, Library, Shelf, Tag, Template class BaseAdmin(MultitenantAdminMixin, admin.ModelAdmin): @@ -88,6 +89,17 @@ class ConfigAdmin(BaseAdmin): fields = ["name", "organization", "template"] +class BioInlineFormSet(MultitenantReadOnlyInlineFormSet, RequiredInlineFormSet): + pass + + +class BioInline(MultitenantAdminMixin, admin.StackedInline): + model = Bio + formset = BioInlineFormSet + fields = ["website", "organization"] + extra = 0 + + admin.site.register(Shelf, ShelfAdmin) admin.site.register(Book, BookAdmin) admin.site.register(Template, TemplateAdmin) diff --git a/tests/testapp/migrations/0007_bio.py b/tests/testapp/migrations/0007_bio.py new file mode 100644 index 000000000..9b141f0ae --- /dev/null +++ b/tests/testapp/migrations/0007_bio.py @@ -0,0 +1,52 @@ +# Generated by Django 5.2.16 on 2026-08-21 09:55 + +import django.db.models.deletion +import swapper +from django.conf import settings +from django.db import migrations, models + +import openwisp_users.mixins + + +class Migration(migrations.Migration): + dependencies = [ + swapper.dependency("openwisp_users", "Organization"), + ("testapp", "0006_alter_book_shelf"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Bio", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("website", models.URLField(blank=True, verbose_name="website")), + ( + "organization", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=swapper.get_model_name("openwisp_users", "Organization"), + verbose_name="organization", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="bios", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={"abstract": False}, + bases=(openwisp_users.mixins.ValidateOrgMixin, models.Model), + ), + ] diff --git a/tests/testapp/models.py b/tests/testapp/models.py index b43c834fe..01a2c83f2 100644 --- a/tests/testapp/models.py +++ b/tests/testapp/models.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.core.exceptions import ValidationError from django.db import models from django.utils.translation import gettext_lazy as _ @@ -70,3 +71,13 @@ class Library(models.Model): def __str__(self): return self.name + + +class Bio(OrgMixin): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="bios" + ) + website = models.URLField(_("website"), blank=True) + + def __str__(self): + return self.website diff --git a/tests/testapp/tests/test_selenium.py b/tests/testapp/tests/test_selenium.py index 124c2192b..2f3ffa003 100644 --- a/tests/testapp/tests/test_selenium.py +++ b/tests/testapp/tests/test_selenium.py @@ -11,8 +11,11 @@ from selenium.webdriver.support.ui import WebDriverWait from swapper import load_model +from openwisp_users import admin as openwisp_users_admin from openwisp_utils.test_selenium_mixins import SeleniumTestMixin +from ..admin import BioInline +from ..models import Bio from .mixins import TestMultitenancyMixin Organization = load_model("openwisp_users", "Organization") @@ -24,6 +27,16 @@ class TestOrganizationAutocompleteField( SeleniumTestMixin, TestMultitenancyMixin, StaticLiveServerTestCase ): + @classmethod + def setUpClass(cls): + openwisp_users_admin.UserAdmin.inlines.append(BioInline) + super().setUpClass() + + @classmethod + def tearDownClass(cls): + openwisp_users_admin.UserAdmin.inlines.remove(BioInline) + super().tearDownClass() + def setUp(self): self.admin = self._create_admin( username=self.admin_username, password=self.admin_password @@ -168,6 +181,84 @@ def test_user_add_form_does_not_hang(self): ) self.logout() + def _create_disabled_bio(self, username): + organization = self._create_org(name=f"disabled-{username}-org") + user = self._create_user(username=username, email=f"{username}@example.com") + bio = Bio.objects.create( + user=user, organization=organization, website="https://example.com" + ) + organization.is_active = False + organization.save() + inline_prefix = Bio._meta.get_field("user").remote_field.get_accessor_name() + path = reverse(f"admin:{User._meta.app_label}_user_change", args=[user.pk]) + return bio, inline_prefix, path, user, organization + + def test_user_admin_disabled_org_bio(self): + with self.subTest("saving user fields"): + bio, inline_prefix, path, user, organization = self._create_disabled_bio( + "disabled-bio-save" + ) + self.login(username=self.admin_username, password=self.admin_password) + self.open(path) + organization_field = self.find_element( + By.ID, f"id_{inline_prefix}-0-organization" + ) + self.assertEqual( + organization_field.get_attribute("value"), str(organization.pk) + ) + self.assertEqual(organization_field.get_attribute("disabled"), "true") + self.assertEqual( + self.find_element(By.ID, f"id_{inline_prefix}-0-website").get_attribute( + "disabled" + ), + "true", + ) + notes_field = self.find_element(By.ID, "id_notes") + notes_field.send_keys("Updated notes") + save_button = self.find_element(By.NAME, "_continue") + self.web_driver.execute_script( + "arguments[0].scrollIntoView({block: 'center'});", save_button + ) + save_button.click() + self.find_element( + By.ID, f"id_{inline_prefix}-0-DELETE", timeout=10, wait_for="presence" + ) + user.refresh_from_db() + self.assertEqual(user.notes, "Updated notes") + self.assertEqual(Bio.objects.filter(pk=bio.pk).count(), 1) + self.assertEqual(bio.organization_id, organization.pk) + self.logout() + + with self.subTest("deleting the disabled-organization bio"): + bio, inline_prefix, path, user, organization = self._create_disabled_bio( + "disabled-bio-delete" + ) + self.login(username=self.admin_username, password=self.admin_password) + self.open(path) + delete_field = self.find_element( + By.ID, f"id_{inline_prefix}-0-DELETE", timeout=10, wait_for="presence" + ) + self.assertEqual(delete_field.is_enabled(), True) + self.find_element( + By.CSS_SELECTOR, f"label[for='id_{inline_prefix}-0-DELETE']" + ).click() + self.assertEqual(delete_field.is_selected(), True) + save_button = self.find_element(By.NAME, "_save") + self.web_driver.execute_script( + "arguments[0].scrollIntoView({block: 'center'});", save_button + ) + save_button.click() + WebDriverWait(self.web_driver, 5).until( + EC.presence_of_element_located( + (By.CSS_SELECTOR, ".messagelist .success") + ) + ) + self.assertEqual(Bio.objects.filter(pk=bio.pk).count(), 0) + user.refresh_from_db() + self.assertEqual(user.username, "disabled-bio-delete") + self.assertEqual(organization.is_active, False) + self.logout() + def test_dynamic_organization_inline_normalizes_shared_value_on_submit(self): # OrganizationUser.organization is a required field, so selecting # "Shared systemwide (no organization)" on an inline row must be From 02f2ce1e63b0295c9303c769914aa4f6f6e06ad3 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Fri, 21 Aug 2026 17:41:53 +0530 Subject: [PATCH 33/34] [fix] Fixed bug in MultitenantReadOnlyInlineFormSet --- openwisp_users/admin.py | 18 ++++++++++++---- tests/testapp/admin.py | 15 +++++++++++++- tests/testapp/migrations/0007_bio.py | 29 ++++++++++++++++++++++++++ tests/testapp/models.py | 7 +++++++ tests/testapp/tests/test_admin.py | 31 ++++++++++++++++++++++++++-- 5 files changed, 93 insertions(+), 7 deletions(-) diff --git a/openwisp_users/admin.py b/openwisp_users/admin.py index 55cfcbbbf..fef01eb46 100644 --- a/openwisp_users/admin.py +++ b/openwisp_users/admin.py @@ -120,21 +120,31 @@ class MultitenantReadOnlyInlineFormSet(BaseInlineFormSet): making their editable fields read-only and preserving deletion. """ + organization_fk_field = "organization" + organization_lookup = "organization" + + def get_organization(self, instance): + organization = instance + for relation in self.organization_lookup.split("__"): + organization = getattr(organization, relation) + return organization + def add_fields(self, form, index): super().add_fields(form, index) instance = getattr(form, "instance", None) - if not (instance and instance.pk and instance.organization_id): + organization_id = getattr(instance, f"{self.organization_fk_field}_id", None) + if not (instance and instance.pk and organization_id): return - if instance.organization.is_active: + if self.get_organization(instance).is_active: return - organization_field = form.fields.get("organization") + organization_field = form.fields.get(self.organization_fk_field) if organization_field is not None: # The formset queryset excludes disabled organizations, # so the current row's value must be added back or the # disabled field fails validation against it. organization_model = organization_field.queryset.model organization_field.queryset = organization_field.queryset | ( - organization_model.objects.filter(pk=instance.organization_id) + organization_model.objects.filter(pk=organization_id) ) pk_name = instance._meta.pk.name for name, field in form.fields.items(): diff --git a/tests/testapp/admin.py b/tests/testapp/admin.py index 8c3349f43..18095f685 100644 --- a/tests/testapp/admin.py +++ b/tests/testapp/admin.py @@ -8,7 +8,7 @@ MultitenantRelatedOrgFilter, ) -from .models import Bio, Book, Config, Library, Shelf, Tag, Template +from .models import Bio, Book, Bookmark, Config, Library, Shelf, Tag, Template class BaseAdmin(MultitenantAdminMixin, admin.ModelAdmin): @@ -100,6 +100,19 @@ class BioInline(MultitenantAdminMixin, admin.StackedInline): extra = 0 +class BookmarkInlineFormSet(MultitenantReadOnlyInlineFormSet): + organization_fk_field = "book" + organization_lookup = "book__organization" + + +class BookmarkInline(MultitenantAdminMixin, admin.StackedInline): + model = Bookmark + formset = BookmarkInlineFormSet + fields = ("book",) + extra = 0 + multitenant_shared_relations = ["book"] + + admin.site.register(Shelf, ShelfAdmin) admin.site.register(Book, BookAdmin) admin.site.register(Template, TemplateAdmin) diff --git a/tests/testapp/migrations/0007_bio.py b/tests/testapp/migrations/0007_bio.py index 9b141f0ae..410790fba 100644 --- a/tests/testapp/migrations/0007_bio.py +++ b/tests/testapp/migrations/0007_bio.py @@ -49,4 +49,33 @@ class Migration(migrations.Migration): options={"abstract": False}, bases=(openwisp_users.mixins.ValidateOrgMixin, models.Model), ), + migrations.CreateModel( + name="Bookmark", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "book", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="testapp.book", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="bookmarks", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), ] diff --git a/tests/testapp/models.py b/tests/testapp/models.py index 01a2c83f2..60b90f142 100644 --- a/tests/testapp/models.py +++ b/tests/testapp/models.py @@ -81,3 +81,10 @@ class Bio(OrgMixin): def __str__(self): return self.website + + +class Bookmark(models.Model): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="bookmarks" + ) + book = models.ForeignKey(Book, on_delete=models.CASCADE) diff --git a/tests/testapp/tests/test_admin.py b/tests/testapp/tests/test_admin.py index e1477368e..b33d560ef 100644 --- a/tests/testapp/tests/test_admin.py +++ b/tests/testapp/tests/test_admin.py @@ -1,14 +1,16 @@ import os import django +from django.contrib import admin from django.contrib.auth import get_user_model -from django.test import TestCase +from django.test import RequestFactory, TestCase from django.urls import reverse from swapper import load_model from openwisp_users.tests.utils import TestOrganizationMixin -from ..models import Template +from ..admin import BookmarkInline +from ..models import Book, Bookmark, Template Organization = load_model("openwisp_users", "Organization") OrganizationUser = load_model("openwisp_users", "OrganizationUser") @@ -44,6 +46,31 @@ def test_accounts_login(self): r, '', html=True ) + def test_indirect_organization_inline_readonly_for_disabled_org(self): + admin_user = self._create_admin() + organization = self._create_org(name="disabled-bookmark-org", is_active=False) + user = self._create_user( + username="disabled-bookmark-user", + email="disabled-bookmark-user@example.com", + ) + book = Book.objects.create( + name="Disabled organization book", + author="Test author", + organization=organization, + ) + bookmark = Bookmark.objects.create(user=user, book=book) + request = RequestFactory().get( + reverse(f"admin:{self.app_label}_user_change", args=[user.pk]) + ) + request.user = admin_user + inline = BookmarkInline(User, admin.site) + formset_class = inline.get_formset(request, user) + formset = formset_class(instance=user, prefix="bookmarks") + form = formset.forms[0] + self.assertEqual(form.instance.pk, bookmark.pk) + self.assertEqual(form.fields["book"].disabled, True) + self.assertEqual(form.fields["book"].queryset.filter(pk=book.pk).exists(), True) + class TestTemplateAdmin(TestOrganizationMixin, TestCase): def test_org_admin_create_shareable_template(self): From abbad50eb7c1812bc46a86c887f31b756fa97b74 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Fri, 21 Aug 2026 22:09:06 +0530 Subject: [PATCH 34/34] [fix] Fixed MultitenantAdminMixin.get_formsets --- openwisp_users/multitenancy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openwisp_users/multitenancy.py b/openwisp_users/multitenancy.py index c20cc16cc..28046b2bc 100644 --- a/openwisp_users/multitenancy.py +++ b/openwisp_users/multitenancy.py @@ -211,7 +211,7 @@ def get_form(self, request, obj=None, **kwargs): return form def get_formset(self, request, obj=None, **kwargs): - formset = super().get_formset(request, obj=None, **kwargs) + formset = super().get_formset(request, obj, **kwargs) self._edit_form(request, formset.form) return formset