From 1eb4d4273a4e3783afffa95c9d8d356ca5d113be Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Mon, 13 Jul 2026 19:15:44 +0530 Subject: [PATCH 1/6] [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 80137c27d..10d31c5be 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 70e0266d7..b3f02fd44 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 af504df38..d440a7a08 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): @@ -583,6 +592,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 062ae0e8b..84ed31236 100644 --- a/openwisp_users/api/serializers.py +++ b/openwisp_users/api/serializers.py @@ -4,6 +4,7 @@ from allauth.account.models import EmailAddress from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission +from django.core.exceptions import ValidationError as DjangoValidationError from django.db import transaction from django.db.models import Q from django.utils.translation import gettext_lazy as _ @@ -20,6 +21,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 @@ -66,7 +80,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): @@ -96,6 +110,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") @@ -110,7 +144,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) @@ -127,7 +161,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) @@ -177,9 +211,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 @@ -282,7 +316,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: @@ -354,20 +388,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 6fb0f1187..12d1b291a 100644 --- a/openwisp_users/apps.py +++ b/openwisp_users/apps.py @@ -197,7 +197,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 52ff71ce5..04d02c745 100644 --- a/openwisp_users/base/models.py +++ b/openwisp_users/base/models.py @@ -469,14 +469,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): @@ -491,6 +491,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) @@ -521,6 +529,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 ab0ad5cda..40f54f7af 100644 --- a/openwisp_users/tests/test_admin.py +++ b/openwisp_users/tests/test_admin.py @@ -26,6 +26,7 @@ from ..admin import OrganizationAdmin, OrganizationOwnerAdmin from ..apps import logger as apps_logger from ..multitenancy import MultitenantAdminMixin +from ..widgets import OrganizationAutocompleteSelect from .utils import ( TestMultitenantAdminMixin, TestOrganizationMixin, @@ -1864,6 +1865,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 4f8437263ce7db9c310818c4836d60341501de94 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Tue, 14 Jul 2026 22:16:37 +0530 Subject: [PATCH 2/6] [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 | 61 ++++++++- 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(+), 39 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 10d31c5be..c4738f9dc 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 b3f02fd44..4c7539b6f 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 d440a7a08..e9935a36d 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 84ed31236..9d3cbd578 100644 --- a/openwisp_users/api/serializers.py +++ b/openwisp_users/api/serializers.py @@ -111,21 +111,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) @@ -208,6 +217,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: @@ -304,20 +319,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 04d02c745..64ab47634 100644 --- a/openwisp_users/base/models.py +++ b/openwisp_users/base/models.py @@ -496,9 +496,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) @@ -530,9 +542,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 40f54f7af..ed0005f49 100644 --- a/openwisp_users/tests/test_admin.py +++ b/openwisp_users/tests/test_admin.py @@ -1894,6 +1894,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) @@ -2035,6 +2068,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 467ead44a..01e4bae84 100644 --- a/openwisp_users/tests/test_api/test_api.py +++ b/openwisp_users/tests/test_api/test_api.py @@ -1,9 +1,12 @@ +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.urls import reverse from django.utils.timezone import localdate, timedelta @@ -60,7 +63,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) @@ -136,6 +139,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") @@ -655,6 +672,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": ""} @@ -761,6 +799,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) @@ -836,7 +893,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 38d38068c0373387852c2416f0ef602d6700dbd6 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Wed, 15 Jul 2026 19:06:58 +0530 Subject: [PATCH 3/6] [fix] Fixes by @coderabbitai --- openwisp_users/admin.py | 6 ++ openwisp_users/api/mixins.py | 19 +++-- openwisp_users/api/serializers.py | 20 ++++-- 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, 164 insertions(+), 75 deletions(-) diff --git a/openwisp_users/admin.py b/openwisp_users/admin.py index e9935a36d..4225c7eda 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 9d3cbd578..5938e16f4 100644 --- a/openwisp_users/api/serializers.py +++ b/openwisp_users/api/serializers.py @@ -13,6 +13,8 @@ from openwisp_utils.api.serializers import ValidatedModelSerializer +from .mixins import DISABLED_ORGANIZATION_ERROR_MESSAGE + Group = load_model("openwisp_users", "Group") Organization = load_model("openwisp_users", "Organization") User = get_user_model() @@ -112,20 +114,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) ) @@ -218,9 +230,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 64ab47634..8e365b64b 100644 --- a/openwisp_users/base/models.py +++ b/openwisp_users/base/models.py @@ -501,12 +501,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 01e4bae84..77ac37ebf 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 @@ -140,6 +137,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() @@ -153,6 +155,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") @@ -658,41 +681,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": ""} @@ -968,3 +956,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 212c450a87dfe4d0baa2fa1a7d28d379d4735328 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Wed, 15 Jul 2026 19:17:29 +0530 Subject: [PATCH 4/6] [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 7aeb9f1de255b4b346846be57f9dd355fcc7239a Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Wed, 15 Jul 2026 19:42:23 +0530 Subject: [PATCH 5/6] [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 b1f968a012a79e08f6dba7cfbe31a982a7ccfdf5 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Thu, 16 Jul 2026 01:44:15 +0530 Subject: [PATCH 6/6] [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), )