Skip to content

[feature] Made disabled organizations readonly but deletable #522#542

Open
pandafy wants to merge 6 commits into
masterfrom
issues/522-disabled-org
Open

[feature] Made disabled organizations readonly but deletable #522#542
pandafy wants to merge 6 commits into
masterfrom
issues/522-disabled-org

Conversation

@pandafy

@pandafy pandafy commented Jul 14, 2026

Copy link
Copy Markdown
Member

Checklist

  • I have read the OpenWISP Contributing Guidelines.
  • I have manually tested the changes proposed in this pull request.
  • I have written new test cases for new code and/or updated existing tests for changes to existing code.
  • I have updated the documentation.

Reference to Existing Issue

Closes #522

Description of Changes

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.

pandafy added 2 commits July 14, 2026 18:39
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
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds disabled-organization write protection across model validation, Django admin, and REST APIs. Disabled organizations remain readable and deletable, while updates, membership changes, ownership assignments, and new selections are restricted. Admin and API opt-outs are supported. Autocomplete and serializer querysets exclude inactive organizations, with transactional validation and rollback coverage.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant AdminOrAPI
  participant WriteGuard
  participant Organization
  Client->>AdminOrAPI: Request object creation or update
  AdminOrAPI->>WriteGuard: Resolve related organization
  WriteGuard->>Organization: Check is_active
  Organization-->>WriteGuard: Active or disabled status
  WriteGuard-->>AdminOrAPI: Permit, reject, or allow delete/read
  AdminOrAPI-->>Client: Response or validation error
Loading

Possibly related issues

  • openwisp/openwisp-radius#729 — Extends the disabled-organization protection behavior introduced by this change.
  • openwisp/openwisp-notifications#472 — Uses the reusable disabled-organization protections introduced by this change.

Suggested labels: enhancement

Suggested reviewers: nemesifier


Caution

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

  • Ignore

❌ Failed checks (2 errors, 2 warnings)

Check name Status Explanation Resolution
Bug Fixes ❌ Error The new TransactionTestCase never reaches the rollback path; the disabled org is rejected in field validation, so removing the atomic rollback would still pass. Create the user with an active org, deactivate it before save (or otherwise force validation to pass first), so the test exercises the half-created-account rollback path.
General Rules ❌ Error DisabledOrgReadOnly fails open on AttributeError, and membership/owner clean() only checks the current org, so disabled rows can be moved to active orgs. Fail closed on org-resolution errors, compare persisted and submitted orgs in membership/owner clean(), and build owner rows before saving; add regressions for those paths.
Features ⚠️ Warning Docs and tests are present, and issue #522 exists/has assignee+project, but the PR description has no screenshots/screen recordings despite touching admin/UI. Add screenshots or a screen recording to the PR description (or explicitly note why none are needed) to satisfy the UI-review requirement.
Changes ⚠️ Warning Docs and tests are updated, but the PR description has no screenshots or screen recordings for the UI/admin changes. Add screenshots or a screen recording to the PR description showing the admin/UI changes (read-only disabled-org forms, autocomplete exclusions, and related behavior).
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is descriptive, matches the disabled-organization write-protection change, and includes the linked issue number.
Description check ✅ Passed The description follows the template with checklist items, issue reference, and change summary; only the screenshot section is omitted.
Linked Issues check ✅ Passed The changes implement the requested read/delete-only guard across admin, DRF, model validation, and selectors for disabled organizations.
Out of Scope Changes check ✅ Passed The modified docs, tests, and code all align with the disabled-organization protection feature; no clear unrelated changes stand out.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issues/522-disabled-org

Comment @coderabbitai help to get the list of available commands.

@kilo-code-bot

kilo-code-bot Bot commented Jul 14, 2026

Copy link
Copy Markdown

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Files Reviewed (11 files)
  • docs/developer/admin-utils.rst
  • openwisp_users/admin.py
  • openwisp_users/api/mixins.py
  • openwisp_users/api/serializers.py
  • openwisp_users/base/models.py
  • openwisp_users/multitenancy.py
  • openwisp_users/tests/test_api/test_api.py
  • openwisp_users/tests/test_models.py
  • tests/testapp/admin.py
  • tests/testapp/tests/test_multitenancy.py
  • tests/testapp/tests/test_selenium.py
Previous Review Summary (commit 4f84372)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit 4f84372)

Status: No Issues Found | Recommendation: Merge

Solid, well-scoped implementation of issue #522 (disabled organizations become read-only but still viewable and deletable). The change is consistent across the admin and REST API layers, and it comes with thorough regression coverage.

A few things done well:

  • DisabledOrgReadOnly correctly allows safe methods and DELETE, honors the allow_disabled_organization_writes opt-out, and safely handles objects without an organization.
  • User + membership creation is now wrapped in transaction.atomic(), and _full_clean_or_raise converts Django ValidationError into a proper DRF 400 instead of an unhandled 500.
  • The no-op re-validation guards in BaseOrganizationUser.clean() / BaseOrganizationOwner.clean() compare against stored DB values so disabling an org with an existing owner/membership doesn't spuriously fail.
  • Tests, docs (developer + user), and selenium coverage were all updated alongside the behavior change, and user-facing strings are marked for translation — in line with AGENTS.md conventions.
Files Reviewed (22 files)
  • docs/developer/admin-utils.rst
  • docs/developer/django-rest-framework-utils.rst
  • docs/user/basic-concepts.rst
  • openwisp_users/admin.py
  • openwisp_users/api/mixins.py
  • openwisp_users/api/permissions.py
  • openwisp_users/api/serializers.py
  • openwisp_users/apps.py
  • openwisp_users/base/models.py
  • openwisp_users/multitenancy.py
  • openwisp_users/tests/test_admin.py
  • openwisp_users/tests/test_api/test_api.py
  • openwisp_users/tests/test_models.py
  • openwisp_users/views.py
  • openwisp_users/widgets.py
  • tests/testapp/tests/test_admin.py
  • tests/testapp/tests/test_multitenancy.py
  • tests/testapp/tests/test_permission_classes.py
  • tests/testapp/tests/test_selenium.py
  • tests/testapp/tests/test_views.py
  • tests/testapp/urls.py
  • tests/testapp/views.py

Reviewed by step-3.7-flash · Input: 163.9K · Output: 34.4K · Cached: 1.6M

@openwisp-companion

Copy link
Copy Markdown

The CI is failing due to transient infrastructure issues (not related to your code). I have restarted the failed jobs automatically (1/3).

@coveralls

coveralls commented Jul 14, 2026

Copy link
Copy Markdown

Coverage Status

coverage: 98.012% (-0.1%) from 98.131% — issues/522-disabled-org into master

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
openwisp_users/api/serializers.py (1)

316-340: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Wrap instance.full_clean() in _full_clean_or_raise()
openwisp_users/api/serializers.py:316-340instance.full_clean() still raises Django ValidationError directly here, while the nested OrganizationUser branch already uses _full_clean_or_raise(). A model-level validation failure on the user will bubble up as a 500 instead of a DRF 400; use _full_clean_or_raise(instance) before save().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openwisp_users/api/serializers.py` around lines 316 - 340, In the create
method, replace the direct instance.full_clean() call with
_full_clean_or_raise(instance) before instance.save(), matching the
OrganizationUser validation path and preserving the surrounding transaction
flow.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@openwisp_users/admin.py`:
- Around line 124-140: The add_fields method currently triggers a per-row
organization lookup through instance.organization. Update the inline formset’s
queryset to use select_related("organization") so each membership’s organization
is loaded with the initial query, while preserving the existing
inactive-organization field behavior in add_fields.

In `@openwisp_users/api/serializers.py`:
- Around line 220-232: The identical disabled-organization error message is
duplicated across serializer and mixin validation. Define one shared
translatable constant in an appropriate API module, update Organization
serializer’s default_error_messages to import and use it, and update the
organization mixin’s error_messages to use the same constant; apply these
changes in openwisp_users/api/serializers.py lines 220-232 and
openwisp_users/api/mixins.py lines 167-187.

In `@openwisp_users/base/models.py`:
- Around line 493-513: Update OrganizationUser.clean to fetch and compare the
persisted user_id alongside organization_id and is_admin in the no-op check.
Ensure changing the membership’s user on a disabled organization raises the
existing “Memberships of a disabled organization cannot be modified.”
ValidationError, while unchanged rows remain allowed.

In `@openwisp_users/multitenancy.py`:
- Around line 133-140: Update the organization queryset logic in the relevant
admin form setup to keep inactive organizations excluded by default, but include
the current object’s organization when disabled_organization_write_protection is
False. Use the existing request/form object context to identify that
organization, and update the documentation section in
docs/developer/admin-utils.rst covering this behavior to reflect the opt-out
exception; no other sites require changes.

---

Outside diff comments:
In `@openwisp_users/api/serializers.py`:
- Around line 316-340: In the create method, replace the direct
instance.full_clean() call with _full_clean_or_raise(instance) before
instance.save(), matching the OrganizationUser validation path and preserving
the surrounding transaction flow.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: ca155518-6f0d-4f87-8d43-31643b3a650c

📥 Commits

Reviewing files that changed from the base of the PR and between 76d3ced and 4f84372.

📒 Files selected for processing (22)
  • docs/developer/admin-utils.rst
  • docs/developer/django-rest-framework-utils.rst
  • docs/user/basic-concepts.rst
  • openwisp_users/admin.py
  • openwisp_users/api/mixins.py
  • openwisp_users/api/permissions.py
  • openwisp_users/api/serializers.py
  • openwisp_users/apps.py
  • openwisp_users/base/models.py
  • openwisp_users/multitenancy.py
  • openwisp_users/tests/test_admin.py
  • openwisp_users/tests/test_api/test_api.py
  • openwisp_users/tests/test_models.py
  • openwisp_users/views.py
  • openwisp_users/widgets.py
  • tests/testapp/tests/test_admin.py
  • tests/testapp/tests/test_multitenancy.py
  • tests/testapp/tests/test_permission_classes.py
  • tests/testapp/tests/test_selenium.py
  • tests/testapp/tests/test_views.py
  • tests/testapp/urls.py
  • tests/testapp/views.py
📜 Review details
⏰ Context from checks skipped due to timeout. (15)
  • GitHub Check: Python==3.13 | django~=5.1.0
  • GitHub Check: Python==3.11 | django~=4.2.0
  • GitHub Check: Python==3.10 | django~=4.2.0
  • GitHub Check: Python==3.10 | django~=5.0.0
  • GitHub Check: Python==3.10 | django~=5.1.0
  • GitHub Check: Python==3.11 | django~=5.0.0
  • GitHub Check: Python==3.13 | django~=5.2.0
  • GitHub Check: Python==3.11 | django~=5.1.0
  • GitHub Check: Python==3.12 | django~=5.1.0
  • GitHub Check: Python==3.11 | django~=5.2.0
  • GitHub Check: Python==3.12 | django~=4.2.0
  • GitHub Check: Python==3.10 | django~=5.2.0
  • GitHub Check: Python==3.12 | django~=5.2.0
  • GitHub Check: Python==3.12 | django~=5.0.0
  • GitHub Check: Kilo Code Review
🧰 Additional context used
📓 Path-based instructions (1)
**/*.py

📄 CodeRabbit inference engine (AGENTS.md)

**/*.py: Mark user-facing strings for translation with Django i18n helpers in Django code
Place imports at the top of the file; only defer imports when necessary (e.g., Django model imports inside functions or methods where the app registry is not yet ready)
Avoid unnecessary blank lines inside function and method bodies
Write comments and docstrings only when they explain why code is shaped a certain way; place comments before the relevant code block instead of scattering them inside it

For Django pull requests, ensure all user-facing strings are marked as translatable using the Django i18n framework.

Files:

  • openwisp_users/apps.py
  • openwisp_users/widgets.py
  • openwisp_users/views.py
  • tests/testapp/tests/test_views.py
  • tests/testapp/tests/test_admin.py
  • tests/testapp/urls.py
  • openwisp_users/api/permissions.py
  • tests/testapp/tests/test_selenium.py
  • openwisp_users/api/mixins.py
  • openwisp_users/tests/test_models.py
  • tests/testapp/tests/test_multitenancy.py
  • tests/testapp/tests/test_permission_classes.py
  • openwisp_users/base/models.py
  • tests/testapp/views.py
  • openwisp_users/multitenancy.py
  • openwisp_users/api/serializers.py
  • openwisp_users/admin.py
  • openwisp_users/tests/test_api/test_api.py
  • openwisp_users/tests/test_admin.py
🔇 Additional comments (22)
docs/developer/django-rest-framework-utils.rst (1)

134-196: LGTM!

Also applies to: 314-322

openwisp_users/api/mixins.py (1)

4-16: LGTM!

Also applies to: 325-325

openwisp_users/api/permissions.py (1)

2-2: LGTM!

Also applies to: 98-122

openwisp_users/api/serializers.py (1)

24-36: LGTM!

Also applies to: 75-83, 113-176, 396-422

openwisp_users/tests/test_api/test_api.py (1)

1-9: LGTM!

Also applies to: 24-24, 120-155, 179-222, 661-695, 749-757, 788-820

tests/testapp/tests/test_permission_classes.py (1)

7-7: LGTM!

Also applies to: 365-463

tests/testapp/views.py (1)

25-29: LGTM!

Also applies to: 216-216, 227-248, 309-312

tests/testapp/urls.py (1)

62-71: LGTM!

openwisp_users/base/models.py (1)

472-479: LGTM!

Also applies to: 543-559

openwisp_users/apps.py (1)

198-201: LGTM!

openwisp_users/tests/test_models.py (1)

413-523: LGTM!

docs/user/basic-concepts.rst (1)

152-201: LGTM!

tests/testapp/tests/test_admin.py (1)

50-51: LGTM!

tests/testapp/tests/test_selenium.py (1)

4-4: LGTM!

Also applies to: 19-19, 33-39, 48-48, 63-66, 80-80, 97-97, 137-137

tests/testapp/tests/test_views.py (1)

67-93: LGTM!

openwisp_users/admin.py (2)

41-41: LGTM!

Also applies to: 102-115, 143-189, 663-669


653-660: 🎯 Functional Correctness

No change needed here. Organization.users uses an explicit through model, so it isn't exposed as a writable admin many-to-many field.

			> Likely an incorrect or invalid review comment.
openwisp_users/multitenancy.py (1)

23-25: LGTM!

Also applies to: 64-123

openwisp_users/tests/test_admin.py (1)

29-29: LGTM!

Also applies to: 1868-2160, 2741-2762

tests/testapp/tests/test_multitenancy.py (1)

1-19: LGTM!

Also applies to: 83-157

openwisp_users/views.py (1)

36-41: LGTM!

openwisp_users/widgets.py (1)

13-13: LGTM!

Comment thread openwisp_users/admin.py
Comment on lines +220 to 232
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:
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated hardcoded error message across two files. Both sites independently hardcode the identical translatable string 'Organization with pk "{pk_value}" does not exist or is disabled.' for the same disabled-organization condition, risking future wording/translation drift.

  • openwisp_users/api/serializers.py#L220-L232: extract the string into a shared module-level constant (e.g. in openwisp_users/api/permissions.py or a small constants module) and import it here for default_error_messages["does_not_exist"].
  • openwisp_users/api/mixins.py#L167-L187: import the same shared constant instead of re-declaring the literal for error_messages["does_not_exist"].
📍 Affects 2 files
  • openwisp_users/api/serializers.py#L220-L232 (this comment)
  • openwisp_users/api/mixins.py#L167-L187
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openwisp_users/api/serializers.py` around lines 220 - 232, The identical
disabled-organization error message is duplicated across serializer and mixin
validation. Define one shared translatable constant in an appropriate API
module, update Organization serializer’s default_error_messages to import and
use it, and update the organization mixin’s error_messages to use the same
constant; apply these changes in openwisp_users/api/serializers.py lines 220-232
and openwisp_users/api/mixins.py lines 167-187.

Comment thread openwisp_users/multitenancy.py Outdated
@pandafy

pandafy commented Jul 15, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review!

@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

@pandafy Sure, I'll perform a full review of the PR.

✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@openwisp_users/api/serializers.py`:
- Around line 410-415: Update the OrganizationUserSerializer membership logic
around org_user.is_admin so is_admin is required and safely validated before
accessing it for an update. Remove the else path that deletes org_user when the
submitted flag is unchanged, preserving the membership while only saving when
the value actually changes.

In `@openwisp_users/tests/test_admin.py`:
- Around line 2027-2037: Update the POST assertion in the relevant admin test to
verify that permission enforcement returns HTTP 403, while retaining the
database-value check if useful. Assert the response status directly so the test
proves has_change_permission blocked the request rather than merely detecting
form validation failure.

In `@openwisp_users/tests/test_models.py`:
- Around line 444-446: In both affected assertions in
openwisp_users/tests/test_models.py (lines 444-446 and 493-496), save
org_user.pk to a local variable before calling org_user.delete(), then query
OrganizationUser using the saved primary key rather than the cleared
org_user.pk. Update both sites consistently while preserving the existing
deletion-count assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: e697432e-a81c-4013-8a23-e16569e2ff3f

📥 Commits

Reviewing files that changed from the base of the PR and between 76d3ced and 4f84372.

📒 Files selected for processing (22)
  • docs/developer/admin-utils.rst
  • docs/developer/django-rest-framework-utils.rst
  • docs/user/basic-concepts.rst
  • openwisp_users/admin.py
  • openwisp_users/api/mixins.py
  • openwisp_users/api/permissions.py
  • openwisp_users/api/serializers.py
  • openwisp_users/apps.py
  • openwisp_users/base/models.py
  • openwisp_users/multitenancy.py
  • openwisp_users/tests/test_admin.py
  • openwisp_users/tests/test_api/test_api.py
  • openwisp_users/tests/test_models.py
  • openwisp_users/views.py
  • openwisp_users/widgets.py
  • tests/testapp/tests/test_admin.py
  • tests/testapp/tests/test_multitenancy.py
  • tests/testapp/tests/test_permission_classes.py
  • tests/testapp/tests/test_selenium.py
  • tests/testapp/tests/test_views.py
  • tests/testapp/urls.py
  • tests/testapp/views.py
📜 Review details
🧰 Additional context used
📓 Path-based instructions (1)
**/*.py

📄 CodeRabbit inference engine (AGENTS.md)

**/*.py: Mark user-facing strings for translation with Django i18n helpers in Django code
Place imports at the top of the file; only defer imports when necessary (e.g., Django model imports inside functions or methods where the app registry is not yet ready)
Avoid unnecessary blank lines inside function and method bodies
Write comments and docstrings only when they explain why code is shaped a certain way; place comments before the relevant code block instead of scattering them inside it

For Django pull requests, ensure all user-facing strings are marked as translatable using the Django i18n framework.

Files:

  • openwisp_users/widgets.py
  • openwisp_users/views.py
  • tests/testapp/urls.py
  • openwisp_users/base/models.py
  • openwisp_users/apps.py
  • tests/testapp/tests/test_multitenancy.py
  • openwisp_users/api/permissions.py
  • tests/testapp/tests/test_admin.py
  • tests/testapp/tests/test_selenium.py
  • openwisp_users/tests/test_models.py
  • tests/testapp/views.py
  • openwisp_users/multitenancy.py
  • openwisp_users/tests/test_admin.py
  • openwisp_users/api/mixins.py
  • openwisp_users/tests/test_api/test_api.py
  • tests/testapp/tests/test_views.py
  • tests/testapp/tests/test_permission_classes.py
  • openwisp_users/api/serializers.py
  • openwisp_users/admin.py
🔇 Additional comments (28)
openwisp_users/base/models.py (3)

493-513: Include user_id in the disabled-membership no-op comparison.

Changing OrganizationUser.user on a disabled organization still passes as unchanged because only organization_id and is_admin are compared. This remains the previously reported membership-write bypass.


476-479: LGTM!


544-559: LGTM!

openwisp_users/multitenancy.py (2)

136-140: Honor the admin write-protection opt-out in the organization queryset.

With disabled_organization_write_protection = False, editing an existing object under a disabled organization still fails because its current organization is excluded from the form field queryset. This remains the previously reported opt-out breakage.


23-25: LGTM!

Also applies to: 64-122

docs/developer/admin-utils.rst (1)

46-62: Document the actual opt-out behavior after fixing the form queryset.

The documented opt-out is currently ineffective for normal edits of objects already assigned to disabled organizations, because the admin form excludes their submitted organization value.

openwisp_users/apps.py (1)

200-200: LGTM!

docs/user/basic-concepts.rst (1)

152-200: LGTM!

openwisp_users/api/permissions.py (1)

2-2: LGTM!

Also applies to: 98-120

openwisp_users/api/mixins.py (2)

4-16: LGTM!

Also applies to: 167-185


322-325: 🔒 Security & Privacy

Create paths already reject disabled organizations Disabled organizations are filtered out in the serializers used by the list/create views, so POST cannot attach them here. DisabledOrgReadOnly is only needed for update-time object checks.

			> Likely an incorrect or invalid review comment.
docs/developer/django-rest-framework-utils.rst (1)

134-196: LGTM!

Also applies to: 314-321

openwisp_users/admin.py (2)

124-140: Previously reported: avoid the per-row organization lookup.

instance.organization still risks an extra query for each inline row; the existing review already requests select_related("organization").


41-41: LGTM!

Also applies to: 102-123, 143-188, 647-669

openwisp_users/api/serializers.py (2)

220-224: Previously reported: share the duplicated disabled-organization error message.

This identical translatable message was already flagged across the serializer and API mixin.


7-34: LGTM!

Also applies to: 75-176, 226-339, 416-420

openwisp_users/tests/test_models.py (1)

413-443: LGTM!

Also applies to: 448-490, 498-524

openwisp_users/tests/test_admin.py (1)

29-29: LGTM!

Also applies to: 1868-2020, 2039-2160, 2741-2762

tests/testapp/tests/test_admin.py (1)

50-51: LGTM!

tests/testapp/tests/test_multitenancy.py (1)

1-19: LGTM!

Also applies to: 83-157

openwisp_users/views.py (1)

36-41: LGTM!

tests/testapp/views.py (1)

25-29: LGTM!

Also applies to: 216-216, 227-248, 309-312

tests/testapp/urls.py (1)

62-71: LGTM!

tests/testapp/tests/test_permission_classes.py (1)

7-7: LGTM!

Also applies to: 365-387, 388-405, 406-447, 448-463

openwisp_users/tests/test_api/test_api.py (1)

1-9: LGTM!

Also applies to: 24-24, 120-155, 179-222, 661-695, 749-757, 788-820

openwisp_users/widgets.py (1)

13-13: LGTM!

tests/testapp/tests/test_views.py (1)

67-92: LGTM!

tests/testapp/tests/test_selenium.py (1)

4-4: LGTM!

Also applies to: 19-19, 33-39, 48-48, 63-66, 80-80, 97-97, 137-137

Comment on lines +410 to +415
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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
ast-grep outline openwisp_users/api/serializers.py \
  --items all \
  --type class \
  --match 'BaseSuperUserSerializer|OrganizationUserSerializer|SuperUserDetailSerializer' \
  --view expanded

rg -n -C5 \
  'organization_users|is_admin|SuperUserDetailSerializer' \
  openwisp_users/api/serializers.py openwisp_users/tests tests

Repository: openwisp/openwisp-users

Length of output: 50380


🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline openwisp_users/api/serializers.py --view expanded

printf '\n--- relevant lines ---\n'
sed -n '235,430p' openwisp_users/api/serializers.py | cat -n

Repository: openwisp/openwisp-users

Length of output: 11232


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find tests that exercise nested organization_users payloads and update behavior.
rg -n -C3 'organization_users|is_admin|delete\(\)|update\(self, instance, validated_data\)' \
  openwisp_users/tests/test_admin.py openwisp_users/tests/test_api.py openwisp_users/tests \
  | head -n 220

Repository: openwisp/openwisp-users

Length of output: 17050


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '235,255p' openwisp_users/api/serializers.py | cat -n
printf '\n--- update() block ---\n'
sed -n '396,420p' openwisp_users/api/serializers.py | cat -n

Repository: openwisp/openwisp-users

Length of output: 2160


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C4 'class OrganizationUser\b|is_admin\b' openwisp_users -g '*.py' | head -n 120

Repository: openwisp/openwisp-users

Length of output: 8135


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C4 'class OrganizationUser\b|is_admin = models\.BooleanField|default=' openwisp_users/models.py openwisp_users/models -g '*.py'

Repository: openwisp/openwisp-users

Length of output: 781


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,220p' openwisp_users/models.py | cat -n

Repository: openwisp/openwisp-users

Length of output: 2217


🌐 Web query:

Django REST Framework ModelSerializer BooleanField model default required=False omitted validated_data

💡 Result:

In Django REST Framework (DRF), the inclusion of a field in validated_data when it is omitted from the input depends on whether the serializer field has a default value defined [1][2]. If you declare a BooleanField (or any field) with required=False but do not explicitly provide a default value, and the field is omitted from the incoming input, the field will typically be excluded from the validated_data dictionary [1][2]. This means the key will be missing entirely from validated_data, rather than being set to a specific value like False [1][2]. Key behaviors to understand: 1. Omitted Fields: If a field is required=False and has no default, it is not populated in validated_data when omitted [1][2]. This allows you to differentiate between a client explicitly sending False and a client omitting the field. 2. Model Defaults: Note that ModelSerializer does not automatically propagate model-level default values to the serializer field's default keyword argument in all cases [2][3]. If the model field has a default=True, but the serializer field is generated as simply required=False without a default argument, the serializer will not automatically inject that model default into validated_data [2][3]. 3. BooleanField and HTML Inputs: If you are using HTML-encoded form input, DRF treats the omission of a BooleanField as False, regardless of whether a default is specified on the field [1]. This is because HTML checkbox inputs omit the value when unchecked [1]. If you want the field to appear in validated_data with a specific value when omitted, you must explicitly set the default keyword argument on the serializer field definition [1]: class MySerializer(serializers.ModelSerializer): my_boolean = serializers.BooleanField(default=False, required=False) # Will appear in validated_data If you are encountering issues where fields appear as False unexpectedly, ensure that you are not using HTML-encoded form inputs and verify that no default argument is inadvertently set on the field [1]. To ensure consistency with your model's defaults, you can explicitly set the default on your serializer fields [2].

Citations:


Require is_admin before updating the membership (openwisp_users/api/serializers.py:410)

  • OrganizationUserSerializer can omit is_admin, so org_user_data["is_admin"] can raise KeyError.
  • The current else branch also deletes the membership when the flag is unchanged.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openwisp_users/api/serializers.py` around lines 410 - 415, Update the
OrganizationUserSerializer membership logic around org_user.is_admin so is_admin
is required and safely validated before accessing it for an update. Remove the
else path that deletes org_user when the submitted flag is unchanged, preserving
the membership while only saving when the value actually changes.

Comment on lines +2027 to +2037
response = self.client.post(
change_path,
{
"user": str(user.pk),
"organization": str(org.pk),
"is_admin": "on",
},
follow=True,
)
org_user.refresh_from_db()
self.assertFalse(org_user.is_admin)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert that permission enforcement returns HTTP 403.

The unchanged database value could also result from form validation failure, so this does not prove has_change_permission blocked the POST.

Proposed fix
             response = self.client.post(
                 change_path,
                 {
                     "user": str(user.pk),
                     "organization": str(org.pk),
                     "is_admin": "on",
                 },
                 follow=True,
             )
+            self.assertEqual(response.status_code, 403)
             org_user.refresh_from_db()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
response = self.client.post(
change_path,
{
"user": str(user.pk),
"organization": str(org.pk),
"is_admin": "on",
},
follow=True,
)
org_user.refresh_from_db()
self.assertFalse(org_user.is_admin)
response = self.client.post(
change_path,
{
"user": str(user.pk),
"organization": str(org.pk),
"is_admin": "on",
},
follow=True,
)
self.assertEqual(response.status_code, 403)
org_user.refresh_from_db()
self.assertFalse(org_user.is_admin)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openwisp_users/tests/test_admin.py` around lines 2027 - 2037, Update the POST
assertion in the relevant admin test to verify that permission enforcement
returns HTTP 403, while retaining the database-value check if useful. Assert the
response status directly so the test proves has_change_permission blocked the
request rather than merely detecting form validation failure.

Comment on lines +444 to +446
# deleting the row must still work
org_user.delete()
self.assertEqual(OrganizationUser.objects.filter(pk=org_user.pk).count(), 0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant test locations with line numbers.
sed -n '420,510p' openwisp_users/tests/test_models.py

# Search for nearby delete/assert patterns in the same file.
rg -n "delete\(\)|filter\(pk=.*count\(\)" openwisp_users/tests/test_models.py

Repository: openwisp/openwisp-users

Length of output: 5608


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the relevant model definitions.
rg -n "class OrganizationUser|class OrganizationOwner|def delete\(" openwisp_users -g '*.py'

# Show the model implementations around any delete overrides if present.
python3 - <<'PY'
from pathlib import Path
paths = [Path("openwisp_users/models.py")]
for p in paths:
    if p.exists():
        text = p.read_text()
        for needle in ["class OrganizationUser", "class OrganizationOwner", "def delete("]:
            if needle in text:
                print(f"\n=== {p} contains {needle} ===")
                # print surrounding chunks
                idx = 0
                while True:
                    idx = text.find(needle, idx)
                    if idx == -1:
                        break
                    start = max(0, text.rfind("\n", 0, idx-800))
                    end = min(len(text), text.find("\n\n", idx+1))
                    print(text[start:end])
                    idx += len(needle)
PY

Repository: openwisp/openwisp-users

Length of output: 2246


Capture the primary key before calling delete(). Model.delete() clears pk, so both assertions currently query pk=None instead of the deleted rows.

  • openwisp_users/tests/test_models.py#L444-L446
  • openwisp_users/tests/test_models.py#L493-L496
📍 Affects 1 file
  • openwisp_users/tests/test_models.py#L444-L446 (this comment)
  • openwisp_users/tests/test_models.py#L493-L496
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openwisp_users/tests/test_models.py` around lines 444 - 446, In both affected
assertions in openwisp_users/tests/test_models.py (lines 444-446 and 493-496),
save org_user.pk to a local variable before calling org_user.delete(), then
query OrganizationUser using the saved primary key rather than the cleared
org_user.pk. Update both sites consistently while preserving the existing
deletion-count assertions.

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,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be present in the docstring.

Comment on lines +686 to +690
with mock.patch.object(
OrganizationUser,
"full_clean",
side_effect=DjangoValidationError("membership boom"),
):

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of using side_effect, a more meaningful test would be to use a disabled organization. Will change the behaviour of the test?

Moreover, we need to test this in a TransactionTestCase.

Comment on lines +16 to +18
class LibraryParentAdmin(MultitenantAdminMixin, admin.ModelAdmin):
# Library has no organization field; it is reached through its Book parent
multitenant_parent = "book"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure this is the right place for placing the ModelAdmin? Is this only for testing?

I'd prefer to move it to the admin.py file, so we can also test this manually.

@kilo-code-bot

kilo-code-bot Bot commented Jul 15, 2026

Copy link
Copy Markdown

Kilo Code Review could not run — your account is out of credits.

Add credits or switch to a free model to enable reviews on this change.

@openwisp-companion

Copy link
Copy Markdown

The CI is failing due to transient infrastructure issues (not related to your code). I have restarted the failed jobs automatically (1/3).

@openwisp-companion

Copy link
Copy Markdown

The CI is failing due to transient infrastructure issues (not related to your code). I have restarted the failed jobs automatically (2/3).

@openwisp-companion

Copy link
Copy Markdown

The CI is failing due to transient infrastructure issues (not related to your code). I have restarted the failed jobs automatically (1/3).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
openwisp_users/api/serializers.py (1)

115-152: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

A genuinely no-op request on a disabled organization is rejected instead of accepted.

allowed requires (reenabling or is_owner_unassignment) even when changed_keys is empty. An empty PATCH body ({}) or a PATCH that only re-sends the current is_active: False unchanged produces changed_keys == set() but still fails this condition, raising 400 for a request that changes nothing.

🔧 Proposed fix
             allowed = (
-                changed_keys <= {"is_active", "owner"}
+                not changed_keys
+                or changed_keys <= {"is_active", "owner"}
                 and (not owner_present or is_owner_unassignment)
                 and (reenabling or is_owner_unassignment)
             )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openwisp_users/api/serializers.py` around lines 115 - 152, The
disabled-organization validation in validate must accept genuine no-op requests.
Update the allowed-condition logic so an empty changed_keys set is valid, while
preserving the existing restrictions for actual changes: only re-enabling and/or
owner unassignment, with no other edits or owner assignment.
openwisp_users/multitenancy.py (1)

93-115: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Allow multitenant_parent models to add shared parents
has_add_permission() hides Add for any non-superuser with no managed orgs, including models that only use multitenant_parent. Those forms can still submit against shared (organization=None) parents, so this blocks valid creates. Restrict the guard to models with a direct organization field.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openwisp_users/multitenancy.py` around lines 93 - 115, Update
has_add_permission so the no-managed-organizations guard only returns False when
self.model has a direct organization field. Remove the multitenant_parent
condition from this check, preserving the existing superuser and User exclusions
and allowing shared parent models to be added.
♻️ Duplicate comments (1)
openwisp_users/api/serializers.py (1)

406-430: 🩺 Stability & Availability | 🟠 Major

KeyError risk when is_admin is omitted from the membership payload.

org_user.is_admin != org_user_data.get("is_admin") uses a safe .get(), but the following assignment org_user.is_admin = org_user_data["is_admin"] (Line 421) indexes the dict directly. If is_admin is omitted from the request (DRF's auto-generated field for a model field with a default typically won't inject a default into validated_data when omitted), .get() returns None, which mismatches an existing True/False value and triggers this branch — raising an uncaught KeyError (500) instead of a clean 400. This mirrors a previously flagged concern on this exact update path that appears unresolved after the restructuring.

🐛 Proposed fix (clarify intended semantics for omitted `is_admin`)
         if org_user:
-            if org_user.is_admin != org_user_data.get("is_admin"):
-                org_user.is_admin = org_user_data["is_admin"]
+            is_admin = org_user_data.get("is_admin", org_user.is_admin)
+            if org_user.is_admin != is_admin:
+                org_user.is_admin = is_admin
                 _full_clean_or_raise(org_user)
                 org_user.save()
             else:
                 org_user.delete()

Note: with this fix, omitting is_admin now falls through to the "unchanged" branch and deletes the membership — confirm that's the intended contract for a submission that only specifies organization without is_admin.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openwisp_users/api/serializers.py` around lines 406 - 430, Update the
existing-membership branch in update so omitted is_admin values cannot cause a
KeyError: distinguish a missing key from an explicitly supplied value and only
assign/save when is_admin is present, while preserving the existing deletion
behavior for the unchanged branch. Confirm and retain the intended contract that
an organization-only membership payload deletes an existing membership.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@openwisp_users/api/serializers.py`:
- Around line 115-152: The disabled-organization validation in validate must
accept genuine no-op requests. Update the allowed-condition logic so an empty
changed_keys set is valid, while preserving the existing restrictions for actual
changes: only re-enabling and/or owner unassignment, with no other edits or
owner assignment.

In `@openwisp_users/multitenancy.py`:
- Around line 93-115: Update has_add_permission so the no-managed-organizations
guard only returns False when self.model has a direct organization field. Remove
the multitenant_parent condition from this check, preserving the existing
superuser and User exclusions and allowing shared parent models to be added.

---

Duplicate comments:
In `@openwisp_users/api/serializers.py`:
- Around line 406-430: Update the existing-membership branch in update so
omitted is_admin values cannot cause a KeyError: distinguish a missing key from
an explicitly supplied value and only assign/save when is_admin is present,
while preserving the existing deletion behavior for the unchanged branch.
Confirm and retain the intended contract that an organization-only membership
payload deletes an existing membership.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7aa1685b-087f-4523-ae5b-6621ac92f5d3

📥 Commits

Reviewing files that changed from the base of the PR and between 4f84372 and 38d3806.

📒 Files selected for processing (9)
  • openwisp_users/admin.py
  • openwisp_users/api/mixins.py
  • openwisp_users/api/serializers.py
  • openwisp_users/base/models.py
  • openwisp_users/multitenancy.py
  • openwisp_users/tests/test_api/test_api.py
  • openwisp_users/tests/test_models.py
  • tests/testapp/admin.py
  • tests/testapp/tests/test_multitenancy.py

@openwisp-companion

Copy link
Copy Markdown

The CI is failing due to transient infrastructure issues (not related to your code). I have restarted the failed jobs automatically (3/3).

@openwisp-companion

Copy link
Copy Markdown

The CI is failing due to transient infrastructure issues (not related to your code). I have restarted the failed jobs automatically (2/3).

@openwisp-companion

Copy link
Copy Markdown

The CI is failing due to transient infrastructure issues (not related to your code). I have restarted the failed jobs automatically (3/3).

@pandafy
pandafy force-pushed the issues/522-disabled-org branch from 8498641 to 7aeb9f1 Compare July 15, 2026 18:49
@openwisp-companion

Copy link
Copy Markdown

The CI is failing due to transient infrastructure issues (not related to your code). I have restarted the failed jobs automatically (1/3).

@nemesifier

Copy link
Copy Markdown
Member

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
openwisp_users/base/models.py (1)

465-479: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Forward **kwargs when constructing the membership.

add_user() still advertises extension fields through **kwargs, but silently drops them. Custom OrganizationUser models relying on these values may fail validation or persist incomplete data.

Proposed fix
-        org_user = OrganizationUser(user=user, organization=self, is_admin=is_admin)
+        org_user = OrganizationUser(
+            user=user,
+            organization=self,
+            is_admin=is_admin,
+            **kwargs,
+        )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openwisp_users/base/models.py` around lines 465 - 479, Update
Organization.add_user to forward the received **kwargs when constructing the
OrganizationUser membership, preserving the existing user, organization, and
is_admin values while allowing custom membership fields to validate and persist.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/user/basic-concepts.rst`:
- Around line 162-183: Update the opening statement in the disabled-organization
documentation to clarify that its data remains readable and deletable only by
users who retain the relevant permissions, notably superusers. Keep the existing
restrictions on managers and other users consistent with the later access
description.

In `@openwisp_users/api/permissions.py`:
- Around line 115-120: Update the organization resolution logic around
get_object_organization so AttributeError from a misconfigured
organization_field or invalid traversal is not treated as a non-organizational
object. Only allow the explicit non-organizational opt-out to bypass the check;
otherwise propagate the configuration error or deny access rather than returning
True.

In `@openwisp_users/api/serializers.py`:
- Around line 165-169: Update both owner-creation paths in the relevant
serializer to instantiate OrganizationOwner without persisting it, call
_full_clean_or_raise() on the instance, then invoke save() only after validation
succeeds. Preserve the existing organization and organization_user assignments
and apply the same ordering consistently in both paths.

In `@openwisp_users/base/models.py`:
- Around line 493-514: Update clean() at openwisp_users/base/models.py:493-514
to reject membership changes when either the persisted organization or submitted
organization is disabled, while preserving no-op saves for existing memberships.
Apply the same persisted-source organization check to the owner-change
validation at openwisp_users/base/models.py:544-560.

In `@openwisp_users/multitenancy.py`:
- Around line 80-91: Separate active-organization write filtering from
read/delete scoping: in openwisp_users/multitenancy.py lines 80-91, update the
admin queryset/permission flow so disabled-organization objects remain readable
and deletable while add/edit choices remain active-only; use the existing
symbols around has_change_permission and organization scoping. In
openwisp_users/api/mixins.py lines 327-330, allow safe methods and DELETE to
resolve memberships and objects from disabled organizations, while preserving
active-only filtering for create/update inputs.

In `@openwisp_users/tests/test_api/test_api.py`:
- Around line 961-980: Update
test_create_user_organization_users_disabled_org_api to use an active
organization when posting the request, then deactivate that organization
immediately before the serializer save occurs so validation succeeds and
SuperUserListSerializer.create reaches the rollback path. Preserve the
assertions that the response is 400 and neither the User nor OrganizationUser
record remains.

---

Outside diff comments:
In `@openwisp_users/base/models.py`:
- Around line 465-479: Update Organization.add_user to forward the received
**kwargs when constructing the OrganizationUser membership, preserving the
existing user, organization, and is_admin values while allowing custom
membership fields to validate and persist.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 981d61ab-3e2c-4503-aff9-7cf964a0bca9

📥 Commits

Reviewing files that changed from the base of the PR and between 76d3ced and b1f968a.

📒 Files selected for processing (23)
  • docs/developer/admin-utils.rst
  • docs/developer/django-rest-framework-utils.rst
  • docs/user/basic-concepts.rst
  • openwisp_users/admin.py
  • openwisp_users/api/mixins.py
  • openwisp_users/api/permissions.py
  • openwisp_users/api/serializers.py
  • openwisp_users/apps.py
  • openwisp_users/base/models.py
  • openwisp_users/multitenancy.py
  • openwisp_users/tests/test_admin.py
  • openwisp_users/tests/test_api/test_api.py
  • openwisp_users/tests/test_models.py
  • openwisp_users/views.py
  • openwisp_users/widgets.py
  • tests/testapp/admin.py
  • tests/testapp/tests/test_admin.py
  • tests/testapp/tests/test_multitenancy.py
  • tests/testapp/tests/test_permission_classes.py
  • tests/testapp/tests/test_selenium.py
  • tests/testapp/tests/test_views.py
  • tests/testapp/urls.py
  • tests/testapp/views.py
📜 Review details
🧰 Additional context used
📓 Path-based instructions (1)
**/*.py

📄 CodeRabbit inference engine (AGENTS.md)

**/*.py: Mark user-facing strings for translation with Django i18n helpers in Django code
Place imports at the top of the file; only defer imports when necessary (e.g., Django model imports inside functions or methods where the app registry is not yet ready)
Avoid unnecessary blank lines inside function and method bodies
Write comments and docstrings only when they explain why code is shaped a certain way; place comments before the relevant code block instead of scattering them inside it

For Django pull requests, ensure all user-facing strings are marked as translatable using the Django i18n framework.

Files:

  • tests/testapp/tests/test_admin.py
  • tests/testapp/urls.py
  • openwisp_users/apps.py
  • tests/testapp/tests/test_views.py
  • openwisp_users/widgets.py
  • tests/testapp/admin.py
  • openwisp_users/views.py
  • openwisp_users/base/models.py
  • tests/testapp/tests/test_selenium.py
  • tests/testapp/tests/test_permission_classes.py
  • openwisp_users/tests/test_models.py
  • openwisp_users/tests/test_admin.py
  • tests/testapp/tests/test_multitenancy.py
  • openwisp_users/api/serializers.py
  • openwisp_users/tests/test_api/test_api.py
  • openwisp_users/api/permissions.py
  • openwisp_users/api/mixins.py
  • openwisp_users/multitenancy.py
  • tests/testapp/views.py
  • openwisp_users/admin.py
🪛 ast-grep (0.44.1)
openwisp_users/api/serializers.py

[warning] 17-17: Loading a Keras model from an untrusted file can execute arbitrary code via Lambda layers or custom objects. Load only trusted models and avoid deserializing custom objects from untrusted sources.
Context: load_model("openwisp_users", "Group")
Note: [CWE-502] Deserialization of Untrusted Data.

(keras-load-model-python)

🔇 Additional comments (26)
openwisp_users/tests/test_admin.py (2)

2027-2037: Assert that the POST is rejected with HTTP 403.

The unchanged database value alone does not prove permission enforcement rather than form validation failure. This concern was already raised in the prior review.


29-29: LGTM!

Also applies to: 1868-2006, 2039-2160, 2741-2762

openwisp_users/apps.py (1)

198-201: LGTM!

tests/testapp/tests/test_admin.py (1)

49-51: LGTM!

tests/testapp/tests/test_views.py (1)

67-93: LGTM!

tests/testapp/tests/test_selenium.py (1)

4-6: LGTM!

Also applies to: 20-20, 29-52, 61-81, 95-95, 112-112, 152-152

docs/developer/admin-utils.rst (1)

34-66: LGTM!

docs/developer/django-rest-framework-utils.rst (1)

134-196: LGTM!

Also applies to: 314-322

openwisp_users/api/serializers.py (2)

420-425: The existing membership can still be deleted when is_admin is unchanged, and an omitted is_admin can still raise KeyError.

This remains covered by the previous review comment.


7-36: LGTM!

Also applies to: 85-85, 115-153, 232-241, 332-349

openwisp_users/tests/test_models.py (2)

444-446: Both deletion assertions still query with a primary key cleared by delete().

Save each primary key before deleting and query with the saved value. This was already raised in the previous review.

Also applies to: 508-511


413-443: LGTM!

Also applies to: 447-507, 512-539

openwisp_users/api/permissions.py (1)

2-2: LGTM!

Also applies to: 98-114

tests/testapp/urls.py (1)

62-71: LGTM!

tests/testapp/tests/test_permission_classes.py (1)

7-7: LGTM!

Also applies to: 365-463

openwisp_users/tests/test_api/test_api.py (1)

7-21: LGTM!

Also applies to: 117-245, 737-808

docs/user/basic-concepts.rst (1)

152-161: LGTM!

Also applies to: 185-200

openwisp_users/multitenancy.py (1)

23-78: LGTM!

Also applies to: 93-170

openwisp_users/admin.py (2)

41-41: LGTM!

Also applies to: 102-195, 669-674


653-667: 🎯 Functional Correctness

OrganizationAdmin doesn’t need local_many_to_many here. The disabled-org readonly rule only needs the editable fields already exposed on this form; no local many-to-many field is present.

			> Likely an incorrect or invalid review comment.
tests/testapp/admin.py (1)

71-79: LGTM!

tests/testapp/tests/test_multitenancy.py (1)

1-22: LGTM!

Also applies to: 87-187

openwisp_users/api/mixins.py (1)

4-23: LGTM!

Also applies to: 169-203

tests/testapp/views.py (1)

25-29: LGTM!

Also applies to: 210-248, 309-312

openwisp_users/views.py (1)

31-42: LGTM!

openwisp_users/widgets.py (1)

12-13: LGTM!

Comment on lines +162 to +183
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 **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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Clarify who can still read and delete disabled-organization data.

“Fully readable and deletable” conflicts with the later statement that managers lose access. State that data remains available to users who retain permission, notably superusers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/user/basic-concepts.rst` around lines 162 - 183, Update the opening
statement in the disabled-organization documentation to clarify that its data
remains readable and deletable only by users who retain the relevant
permissions, notably superusers. Keep the existing restrictions on managers and
other users consistent with the later access description.

Comment on lines +115 to +120
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not fail open when organization resolution is misconfigured.

Catching every AttributeError allows writes if organization_field is misspelled or its traversal is invalid. Fail closed or require non-organizational views to use the explicit opt-out.

         try:
             organization = self.get_object_organization(view, obj)
         except AttributeError:
-            # object has no organization field, rule not applicable
-            return True
+            return False
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
try:
organization = self.get_object_organization(view, obj)
except AttributeError:
return False
return organization is None or organization.is_active
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openwisp_users/api/permissions.py` around lines 115 - 120, Update the
organization resolution logic around get_object_organization so AttributeError
from a misconfigured organization_field or invalid traversal is not treated as a
non-organizational object. Only allow the explicit non-organizational opt-out to
bypass the check; otherwise propagate the configuration error or deny access
rather than returning True.

Comment on lines 165 to 169
org_owner = OrganizationOwner.objects.create(
organization=instance, organization_user=org_user
)
org_owner.full_clean()
_full_clean_or_raise(org_owner)
org_owner.save()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -C4 'post_save|`@receiver`\s*\(' openwisp_users -g '*.py'

Repository: openwisp/openwisp-users

Length of output: 4150


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the serializer around both owner-creation paths
sed -n '140,210p' openwisp_users/api/serializers.py | cat -n

# Inspect signal hookups and handlers related to owner/user creation
sed -n '1,240p' openwisp_users/apps.py | cat -n

# Find any post_save / pre_save handlers that might be triggered by OrganizationOwner or OrganizationUser
rg -n -C3 'OrganizationOwner|OrganizationUser|post_save|pre_save|`@receiver`\s*\(' openwisp_users -g '*.py'

Repository: openwisp/openwisp-users

Length of output: 50379


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

serializer = Path("openwisp_users/api/serializers.py").read_text()
apps = Path("openwisp_users/apps.py").read_text()

# Show the two owner-creation sequences in the serializer.
for pat in [
    r"OrganizationOwner\.objects\.create\([\s\S]*?_full_clean_or_raise\(org_owner\)[\s\S]*?org_owner\.save\(\)",
    r"OrganizationOwner\.objects\.create\([\s\S]*?_full_clean_or_raise\(org_owner\)[\s\S]*?org_owner\.save\(\)",
]:
    m = re.search(pat, serializer)
    print("serializer_match:", bool(m))

# Show save-signal wiring for OrganizationOwner.
print("OrganizationOwner post_save connected:", "post_save.connect(" in apps and "sender=model" in apps and "OrganizationOwner" in apps)
print("OrganizationOwner pre_save connected:", "pre_save.connect(" in apps and "OrganizationOwner" in apps)

# Extract the receiver used for OrganizationOwner/OrganizationUser saves.
for line in apps.splitlines():
    if "update_organizations_dict" in line or "pre_save_update_organizations_dict" in line or "create_organization_owner" in line:
        print(line)
PY

Repository: openwisp/openwisp-users

Length of output: 728


Validate owner objects before saving. objects.create() triggers OrganizationOwner save receivers, and update_organizations_dict repopulates the user's cache from the uncommitted row; if full_clean() raises afterward, the DB rolls back but the cache can still reflect the invalid owner. Build the instance, run _full_clean_or_raise(), then save it in both owner-creation paths.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openwisp_users/api/serializers.py` around lines 165 - 169, Update both
owner-creation paths in the relevant serializer to instantiate OrganizationOwner
without persisting it, call _full_clean_or_raise() on the instance, then invoke
save() only after validation succeeds. Preserve the existing organization and
organization_user assignments and apply the same ordering consistently in both
paths.

Comment on lines 493 to +514
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")
.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.")
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Check both the persisted and submitted organizations.

Both guards can be bypassed by moving a row from a disabled organization to an active one.

  • openwisp_users/base/models.py#L493-L514: when membership fields change, reject the update if either the persisted or submitted organization is disabled.
  • openwisp_users/base/models.py#L544-L560: apply the same persisted-source check to owner changes.
📍 Affects 1 file
  • openwisp_users/base/models.py#L493-L514 (this comment)
  • openwisp_users/base/models.py#L544-L560
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openwisp_users/base/models.py` around lines 493 - 514, Update clean() at
openwisp_users/base/models.py:493-514 to reject membership changes when either
the persisted organization or submitted organization is disabled, while
preserving no-op saves for existing memberships. Apply the same persisted-source
organization check to the owner-change validation at
openwisp_users/base/models.py:544-560.

Comment on lines +80 to +91
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 = self._get_object_organization(obj)
if organization is not None and not organization.is_active:
return False
return super().has_change_permission(request, obj)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Separate read/delete organization scope from active write scope.

Both guards run after earlier authorization layers have already excluded disabled organizations, so non-superuser managers cannot view or delete their objects.

  • openwisp_users/multitenancy.py#L80-L91: include disabled organizations in admin read/delete queryset scoping; keep active-only organizations for add and edit choices.
  • openwisp_users/api/mixins.py#L327-L330: allow safe methods and DELETE to resolve memberships and objects from disabled organizations; retain active-only filtering for create/update inputs.
📍 Affects 2 files
  • openwisp_users/multitenancy.py#L80-L91 (this comment)
  • openwisp_users/api/mixins.py#L327-L330
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openwisp_users/multitenancy.py` around lines 80 - 91, Separate
active-organization write filtering from read/delete scoping: in
openwisp_users/multitenancy.py lines 80-91, update the admin queryset/permission
flow so disabled-organization objects remain readable and deletable while
add/edit choices remain active-only; use the existing symbols around
has_change_permission and organization scoping. In openwisp_users/api/mixins.py
lines 327-330, allow safe methods and DELETE to resolve memberships and objects
from disabled organizations, while preserving active-only filtering for
create/update inputs.

Comment on lines +961 to +980
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
ast-grep outline openwisp_users/api/serializers.py \
  --items all --match 'OrgUserCustomPrimarykeyRelatedField|SuperUserListSerializer'

rg -n -C4 \
  'OrgUserCustomPrimarykeyRelatedField|organization_users\s*=' \
  openwisp_users/api/serializers.py

Repository: openwisp/openwisp-users

Length of output: 1832


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '231,340p' openwisp_users/api/serializers.py | cat -n
printf '\n---\n'
sed -n '1,220p' openwisp_users/tests/test_api/test_api.py | cat -n | sed -n '900,1020p'

Repository: openwisp/openwisp-users

Length of output: 4525


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the relevant serializer and test lines with context.
sed -n '231,330p' openwisp_users/api/serializers.py | cat -n
printf '\n--- TEST ---\n'
sed -n '950,990p' openwisp_users/tests/test_api/test_api.py | cat -n

Repository: openwisp/openwisp-users

Length of output: 5710


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the create/validation logic for organization_users.
rg -n -C8 'def validate_email|def create|organization_users|OrgUserCustomPrimarykeyRelatedField|get_queryset' openwisp_users/api/serializers.py

# Show the test case around the cited lines.
sed -n '955,985p' openwisp_users/tests/test_api/test_api.py | cat -n

Repository: openwisp/openwisp-users

Length of output: 8070


🏁 Script executed:

#!/bin/bash
set -euo pipefail

nl -ba openwisp_users/api/serializers.py | sed -n '231,330p'

Repository: openwisp/openwisp-users

Length of output: 201


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '326,390p' openwisp_users/api/serializers.py | cat -n

Repository: openwisp/openwisp-users

Length of output: 2954


Use an active org to hit the rollback path

OrgUserCustomPrimarykeyRelatedField.get_queryset() filters out inactive orgs during is_valid(), so this request fails before SuperUserListSerializer.create() writes the user. Create with an active org, then deactivate it before save() to exercise the rollback branch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openwisp_users/tests/test_api/test_api.py` around lines 961 - 980, Update
test_create_user_organization_users_disabled_org_api to use an active
organization when posting the request, then deactivate that organization
immediately before the serializer save occurs so validation succeeds and
SuperUserListSerializer.create reaches the rollback path. Preserve the
assertions that the response is 400 and neither the User nor OrganizationUser
record remains.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Development

Successfully merging this pull request may close these issues.

[change] Make disabled organizations readonly but deletable

3 participants