Skip to content

Commit 38d3806

Browse files
committed
[fix] Fixes by @coderabbitai
1 parent 4f84372 commit 38d3806

9 files changed

Lines changed: 164 additions & 75 deletions

File tree

openwisp_users/admin.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,12 @@ class OrganizationUserInline(admin.StackedInline):
147147
fields = ("organization", "is_admin")
148148
autocomplete_fields = ("organization",)
149149

150+
def get_queryset(self, request):
151+
# OrganizationUserInlineFormSet.add_fields() reads
152+
# instance.organization.is_active for every row; select_related
153+
# folds that per-row query into this one.
154+
return super().get_queryset(request).select_related("organization")
155+
150156
def get_formset(self, request, obj=None, **kwargs):
151157
"""
152158
In form dropdowns, display only active organizations;

openwisp_users/api/mixins.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717

1818
Organization = swapper.load_model("openwisp_users", "Organization")
1919

20+
DISABLED_ORGANIZATION_ERROR_MESSAGE = _(
21+
'Organization with pk "{pk_value}" does not exist or is disabled.'
22+
)
23+
2024

2125
class OrgLookup:
2226
@property
@@ -165,8 +169,9 @@ def _user_attr(self):
165169
def filter_fields(self):
166170
user = self.context["request"].user
167171
# superuser can see everything, except disabled organizations
168-
superuser = user.is_superuser or user.is_anonymous
169-
if not superuser:
172+
# The anonymouse use case exist so we don't run into errors with swagger
173+
is_superuser_or_anonymous = user.is_superuser or user.is_anonymous
174+
if not is_superuser_or_anonymous:
170175
# non superusers can see only items of organizations
171176
# they're related to
172177
organization_filter = getattr(user, self._user_attr)
@@ -176,15 +181,15 @@ def filter_fields(self):
176181
# disabled organizations are excluded for everyone, superusers
177182
# included, since they can only be re-enabled, not written to
178183
queryset = self.fields[field].queryset.filter(is_active=True)
179-
self.fields[field].error_messages["does_not_exist"] = _(
180-
'Organization with pk "{pk_value}" does not exist or is disabled.'
181-
)
182-
if not superuser:
184+
self.fields[field].error_messages[
185+
"does_not_exist"
186+
] = DISABLED_ORGANIZATION_ERROR_MESSAGE
187+
if not is_superuser_or_anonymous:
183188
self.fields[field].allow_null = False
184189
queryset = queryset.filter(pk__in=organization_filter)
185190
self.fields[field].queryset = queryset
186191
continue
187-
if superuser:
192+
if is_superuser_or_anonymous:
188193
continue
189194
conditions = Q(**{self.organization_lookup: organization_filter})
190195
if self.include_shared:

openwisp_users/api/serializers.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313

1414
from openwisp_utils.api.serializers import ValidatedModelSerializer
1515

16+
from .mixins import DISABLED_ORGANIZATION_ERROR_MESSAGE
17+
1618
Group = load_model("openwisp_users", "Group")
1719
Organization = load_model("openwisp_users", "Organization")
1820
User = get_user_model()
@@ -112,20 +114,30 @@ class Meta:
112114

113115
def validate(self, data):
114116
if self.instance and not self.instance.is_active:
115-
keys = set(data.keys())
116117
owner_data = data.get("owner") or {}
117118
owner_present = "owner" in data
118119
is_owner_unassignment = (
119120
owner_present and owner_data.get("organization_user") is None
120121
)
121122
reenabling = data.get("is_active") is True
123+
# A key whose submitted value matches the value already stored is
124+
# not a change, so a read-modify-write PUT that resends every
125+
# field unchanged except is_active must not be rejected just
126+
# because e.g. "name" is present in the payload.
127+
changed_keys = {
128+
key
129+
for key in data
130+
if key != "owner" and getattr(self.instance, key) != data[key]
131+
}
132+
if owner_present:
133+
changed_keys.add("owner")
122134
# While disabled, only re-enabling (Is active) and/or unassigning
123135
# the owner are allowed, and neither can be combined with any other
124136
# change (editing a field or assigning an owner). This matches the
125137
# admin interface, which locks every field except Is active, so the
126138
# admin, the API and the docs tell the same story.
127139
allowed = (
128-
keys <= {"is_active", "owner"}
140+
changed_keys <= {"is_active", "owner"}
129141
and (not owner_present or is_owner_unassignment)
130142
and (reenabling or is_owner_unassignment)
131143
)
@@ -218,9 +230,7 @@ def update(self, instance, validated_data):
218230

219231
class OrgUserCustomPrimarykeyRelatedField(serializers.PrimaryKeyRelatedField):
220232
default_error_messages = {
221-
"does_not_exist": _(
222-
'Organization with pk "{pk_value}" does not exist or is disabled.'
223-
),
233+
"does_not_exist": DISABLED_ORGANIZATION_ERROR_MESSAGE,
224234
}
225235

226236
def get_queryset(self):

openwisp_users/base/models.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -501,12 +501,13 @@ def clean(self):
501501
# disabled organization must not fail.
502502
db_values = (
503503
self._meta.model.objects.filter(pk=self.pk)
504-
.values("organization_id", "is_admin")
504+
.values("organization_id", "is_admin", "user_id")
505505
.first()
506506
)
507507
if db_values is None or (
508508
db_values["organization_id"] != self.organization_id
509509
or db_values["is_admin"] != self.is_admin
510+
or db_values["user_id"] != self.user_id
510511
):
511512
raise ValidationError(
512513
_("Memberships of a disabled organization cannot be modified.")

openwisp_users/multitenancy.py

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ def has_change_permission(self, request, obj=None):
9292

9393
def has_add_permission(self, request, *args, **kwargs):
9494
"""
95-
Hide the Add button from operators who manage no active organization:
95+
Hide the Add button from admins who manage no active organization:
9696
the organization dropdown would be empty and the form could never be
9797
submitted. Does not apply to the user admin or to models without an
9898
organization (directly or through ``multitenant_parent``).
@@ -106,22 +106,15 @@ def has_add_permission(self, request, *args, **kwargs):
106106
and self.model != User
107107
and not request.user.organizations_managed
108108
):
109-
org_field = (
110-
self.model._meta.get_field("organization")
111-
if hasattr(self.model, "organization")
112-
else None
113-
)
114-
# If the model has a required organization field (OrgMixin, not
115-
# ShareableOrgMixin which allows null/blank), the dropdown would be
116-
# empty — block. If it's optional or reached through a parent,
117-
# the form can still be submitted without picking an org.
118-
if org_field is not None:
119-
return False
120-
if org_field is None and self.multitenant_parent:
109+
# Any model with an organization field (directly, or reached
110+
# through multitenant_parent) is blocked: _edit_form() makes the
111+
# field required for non-superusers, so the form could not be
112+
# submitted without an active organization to pick anyway.
113+
if hasattr(self.model, "organization") or self.multitenant_parent:
121114
return False
122115
return super().has_add_permission(request, *args, **kwargs)
123116

124-
def _edit_form(self, request, form):
117+
def _edit_form(self, request, form, obj=None):
125118
"""
126119
Modifies the form querysets as follows;
127120
if current user is not superuser:
@@ -131,21 +124,35 @@ def _edit_form(self, request, form):
131124
* do not allow organization field to be empty (shared org)
132125
else show everything
133126
Organization choices always exclude disabled organizations,
134-
superusers included.
127+
superusers included, except an admin that opted out of write
128+
protection (``disabled_organization_write_protection = False``)
129+
keeps the edited object's own disabled organization selectable,
130+
or the form could never be saved.
135131
"""
136132
fields = form.base_fields
137133
user = request.user
138134
org_field = fields.get("organization")
135+
keep_disabled_org_pk = None
136+
if not self.disabled_organization_write_protection and obj is not None:
137+
organization = self._get_object_organization(obj)
138+
if organization is not None and not organization.is_active:
139+
keep_disabled_org_pk = organization.pk
139140
if org_field:
140-
org_field.queryset = org_field.queryset.filter(is_active=True)
141+
allowed = Q(is_active=True)
142+
if keep_disabled_org_pk is not None:
143+
allowed |= Q(pk=keep_disabled_org_pk)
144+
org_field.queryset = org_field.queryset.filter(allowed)
141145
if user.is_superuser and org_field and not org_field.required:
142146
org_field.empty_label = SHARED_SYSTEMWIDE_LABEL
143147
elif not user.is_superuser:
144148
orgs_pk = user.organizations_managed
145149
# organizations relation;
146150
# may be readonly and not present in field list
147151
if org_field:
148-
org_field.queryset = org_field.queryset.filter(pk__in=orgs_pk)
152+
managed = Q(pk__in=orgs_pk)
153+
if keep_disabled_org_pk is not None:
154+
managed |= Q(pk=keep_disabled_org_pk)
155+
org_field.queryset = org_field.queryset.filter(managed)
149156
org_field.empty_label = None
150157
org_field.required = True
151158
# other relations
@@ -160,7 +167,7 @@ def _edit_form(self, request, form):
160167

161168
def get_form(self, request, obj=None, **kwargs):
162169
form = super().get_form(request, obj, **kwargs)
163-
self._edit_form(request, form)
170+
self._edit_form(request, form, obj)
164171
return form
165172

166173
def get_formset(self, request, obj=None, **kwargs):

openwisp_users/tests/test_api/test_api.py

Lines changed: 49 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
1-
from unittest import mock
2-
31
import django
42
from allauth.account.models import EmailAddress
53
from django.contrib import auth
64
from django.contrib.auth import get_user_model
75
from django.contrib.auth.models import Permission
86
from django.core import mail
9-
from django.core.exceptions import ValidationError as DjangoValidationError
10-
from django.test import TestCase
7+
from django.test import TestCase, TransactionTestCase
118
from django.urls import reverse
129
from django.utils.timezone import localdate, timedelta
1310
from swapper import load_model
@@ -140,6 +137,11 @@ def test_patch_disabled_organization_reenable_api(self):
140137
self.assertTrue(org1.is_active)
141138

142139
def test_reenable_disabled_organization_with_field_edit_api(self):
140+
"""
141+
Re-enabling (is_active) and editing another field in the same
142+
request is rejected: re-enabling and editing must be two separate
143+
requests, matching the admin and the docs.
144+
"""
143145
org1 = self._get_org()
144146
org1.is_active = False
145147
org1.save()
@@ -153,6 +155,27 @@ def test_reenable_disabled_organization_with_field_edit_api(self):
153155
self.assertEqual(org1.is_active, False)
154156
self.assertEqual(org1.name, "test org")
155157

158+
def test_reenable_disabled_organization_via_put_api(self):
159+
org1 = self._get_org()
160+
org1.is_active = False
161+
org1.save()
162+
path = reverse("users:organization_detail", args=(org1.pk,))
163+
# a PUT always resends every required field, "name" included; since
164+
# its value is unchanged it must not count as an edit and block the
165+
# re-enable, the way a read-modify-write client would use PUT
166+
data = {
167+
"name": org1.name,
168+
"is_active": True,
169+
"slug": org1.slug,
170+
"description": org1.description,
171+
"email": org1.email,
172+
"url": org1.url,
173+
}
174+
response = self.client.put(path, data, content_type="application/json")
175+
self.assertEqual(response.status_code, 200)
176+
org1.refresh_from_db()
177+
self.assertTrue(org1.is_active)
178+
156179
def test_create_organization_owner_api(self):
157180
user1 = self._create_user(username="user1", email="user1@email.com")
158181
org1 = self._create_org(name="org1")
@@ -658,41 +681,6 @@ def test_create_user_with_group_org_user_api(self):
658681
r = self.client.post(path, data, content_type="application/json")
659682
self.assertEqual(r.status_code, 201)
660683

661-
def test_create_user_organization_users_disabled_org_api(self):
662-
path = reverse("users:user_list")
663-
org1 = self._create_org(name="disabled-org", is_active=False)
664-
data = {
665-
"username": "tester",
666-
"email": "tester@test.com",
667-
"password": "password",
668-
"organization_users": {"is_admin": False, "organization": org1.pk},
669-
}
670-
r = self.client.post(path, data, content_type="application/json")
671-
self.assertEqual(r.status_code, 400)
672-
self.assertEqual(User.objects.filter(username="tester").count(), 0)
673-
self.assertEqual(OrganizationUser.objects.filter(organization=org1).count(), 0)
674-
675-
def test_create_user_membership_failure_rolls_back_user_api(self):
676-
# A membership validation failure after the user row is written must
677-
# roll the user back instead of leaving a half-created account behind.
678-
path = reverse("users:user_list")
679-
org1 = self._get_org()
680-
data = {
681-
"username": "rollbackuser",
682-
"email": "rollbackuser@test.com",
683-
"password": "password",
684-
"organization_users": {"is_admin": False, "organization": org1.pk},
685-
}
686-
with mock.patch.object(
687-
OrganizationUser,
688-
"full_clean",
689-
side_effect=DjangoValidationError("membership boom"),
690-
):
691-
r = self.client.post(path, data, content_type="application/json")
692-
self.assertEqual(r.status_code, 400)
693-
self.assertEqual(User.objects.filter(username="rollbackuser").count(), 0)
694-
self.assertEqual(OrganizationUser.objects.filter(organization=org1).count(), 0)
695-
696684
def test_post_with_no_email(self):
697685
path = reverse("users:user_list")
698686
data = {"username": "", "email": "", "password": ""}
@@ -968,3 +956,25 @@ def test_expiration_date_none_api(self):
968956
self.assertIsNone(r.data["expiration_date"])
969957
user.refresh_from_db()
970958
self.assertIsNone(user.expiration_date)
959+
960+
961+
class TestUsersApiTransaction(TestOrganizationMixin, TransactionTestCase):
962+
def setUp(self):
963+
self.client.force_login(self._get_admin())
964+
965+
def test_create_user_organization_users_disabled_org_api(self):
966+
# A membership validation failure (here, the disabled-organization
967+
# guard) after the user row is written must roll the user back
968+
# instead of leaving a half-created account behind.
969+
path = reverse("users:user_list")
970+
org1 = self._create_org(name="disabled-org", is_active=False)
971+
data = {
972+
"username": "tester",
973+
"email": "tester@test.com",
974+
"password": "password",
975+
"organization_users": {"is_admin": False, "organization": org1.pk},
976+
}
977+
r = self.client.post(path, data, content_type="application/json")
978+
self.assertEqual(r.status_code, 400)
979+
self.assertEqual(User.objects.filter(username="tester").count(), 0)
980+
self.assertEqual(OrganizationUser.objects.filter(organization=org1).count(), 0)

openwisp_users/tests/test_models.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,21 @@ def test_organization_user_clean_disabled_organization(self):
468468
# Django admin cannot save a user who has a disabled-org membership
469469
org_user.full_clean()
470470

471+
with self.subTest("reassign the user of a disabled organization membership"):
472+
org = self._create_org(name="test-org-reassign-user")
473+
user = self._create_user(username="user6", email="user6@example.com")
474+
other_user = self._create_user(username="user7", email="user7@example.com")
475+
org_user = self._create_org_user(organization=org, user=user)
476+
org.is_active = False
477+
org.save()
478+
org_user.refresh_from_db()
479+
org_user.user = other_user
480+
with self.assertRaisesMessage(
481+
ValidationError,
482+
"Memberships of a disabled organization cannot be modified.",
483+
):
484+
org_user.full_clean()
485+
471486
def test_organization_owner_clean_disabled_organization(self):
472487
with self.subTest("assign an owner to a disabled organization"):
473488
org = self._create_org(name="disabled-org-owner")

tests/testapp/admin.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,13 @@ class TagAdmin(BaseAdmin):
6868
pass
6969

7070

71+
class LibraryParentAdmin(MultitenantAdminMixin, admin.ModelAdmin):
72+
# Library has no organization field; it is reached through its Book parent
73+
multitenant_parent = "book"
74+
75+
7176
admin.site.register(Shelf, ShelfAdmin)
7277
admin.site.register(Book, BookAdmin)
7378
admin.site.register(Template, TemplateAdmin)
74-
admin.site.register(Library)
79+
admin.site.register(Library, LibraryParentAdmin)
7580
admin.site.register(Tag, TagAdmin)

0 commit comments

Comments
 (0)