Skip to content

Commit 017eddb

Browse files
committed
[admin/api] Make batch user creation organization field readonly #609
This change prevents modification of the organization field for existing RadiusBatch objects in both admin interface and REST API. When editing existing batch objects, the organization field is now readonly to maintain data integrity and prevent accidental organization changes. Changes: - Added organization field to readonly_fields in RadiusBatchAdmin for existing objects - Created RadiusBatchUpdateSerializer with organization as readonly field - Added BatchUpdateView for handling batch detail API operations - Added comprehensive tests for both admin and API readonly functionality Fixes #609
1 parent 3173d05 commit 017eddb

File tree

6 files changed

+199
-5
lines changed

6 files changed

+199
-5
lines changed

openwisp_radius/admin.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -463,18 +463,17 @@ def delete_selected_batches(self, request, queryset):
463463
)
464464

465465
def get_readonly_fields(self, request, obj=None):
466-
readonly_fields = super(RadiusBatchAdmin, self).get_readonly_fields(
467-
request, obj
468-
)
466+
readonly_fields = super().get_readonly_fields(request, obj)
469467
if obj:
470-
return (
468+
return readonly_fields + (
471469
"strategy",
470+
"organization",
472471
"prefix",
473472
"csvfile",
474473
"number_of_users",
475474
"users",
476475
"expiration_date",
477-
) + readonly_fields
476+
)
478477
return readonly_fields
479478

480479

openwisp_radius/api/serializers.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,34 @@ class Meta:
438438
read_only_fields = ("created", "modified", "user_credentials")
439439

440440

441+
class RadiusBatchUpdateSerializer(RadiusBatchSerializer):
442+
"""
443+
Serializer for updating RadiusBatch objects.
444+
Makes the organization field readonly for existing objects.
445+
"""
446+
447+
def validate(self, data):
448+
"""
449+
Simplified validation for partial updates.
450+
Only validates essential fields based on strategy.
451+
"""
452+
strategy = data.get("strategy") or (self.instance and self.instance.strategy)
453+
454+
if (strategy == "prefix" and
455+
"number_of_users" in data and
456+
not data.get("number_of_users")):
457+
raise serializers.ValidationError(
458+
{"number_of_users": _("The field number_of_users cannot be empty")}
459+
)
460+
461+
return serializers.ModelSerializer.validate(self, data)
462+
463+
class Meta:
464+
model = RadiusBatch
465+
fields = "__all__"
466+
read_only_fields = ("created", "modified", "user_credentials", "organization")
467+
468+
441469
class PasswordResetSerializer(BasePasswordResetSerializer):
442470
input = serializers.CharField()
443471
email = None

openwisp_radius/api/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ def get_api_urls(api_views=None):
7878
name="phone_number_change",
7979
),
8080
path("radius/batch/", api_views.batch, name="batch"),
81+
path("radius/batch/<uuid:pk>/", api_views.batch_detail, name="batch_detail"),
8182
path(
8283
"radius/organization/<slug:slug>/batch/<uuid:pk>/pdf/",
8384
api_views.download_rad_batch_pdf,

openwisp_radius/api/views.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
GenericAPIView,
3232
ListAPIView,
3333
RetrieveAPIView,
34+
RetrieveUpdateAPIView,
3435
)
3536
from rest_framework.permissions import (
3637
DjangoModelPermissions,
@@ -62,6 +63,7 @@
6263
ChangePhoneNumberSerializer,
6364
RadiusAccountingSerializer,
6465
RadiusBatchSerializer,
66+
RadiusBatchUpdateSerializer,
6567
UserRadiusUsageSerializer,
6668
ValidatePhoneTokenSerializer,
6769
)
@@ -144,6 +146,35 @@ def validate_membership(self, user):
144146
raise serializers.ValidationError({"non_field_errors": [message]})
145147

146148

149+
class BatchUpdateView(ThrottledAPIMixin, RetrieveUpdateAPIView):
150+
"""
151+
API view for updating existing RadiusBatch objects.
152+
Organization field is readonly for existing objects.
153+
"""
154+
authentication_classes = (BearerAuthentication, SessionAuthentication)
155+
permission_classes = (IsOrganizationManager, IsAdminUser, DjangoModelPermissions)
156+
queryset = RadiusBatch.objects.none()
157+
serializer_class = RadiusBatchSerializer
158+
159+
def get_queryset(self):
160+
"""Filter batches by user's organization membership"""
161+
user = self.request.user
162+
if user.is_superuser:
163+
return RadiusBatch.objects.all()
164+
return RadiusBatch.objects.filter(
165+
organization__in=user.organizations_managed
166+
)
167+
168+
def get_serializer_class(self):
169+
"""Use RadiusBatchUpdateSerializer for PUT/PATCH requests"""
170+
if self.request.method in ('PUT', 'PATCH'):
171+
return RadiusBatchUpdateSerializer
172+
return RadiusBatchSerializer
173+
174+
175+
batch_detail = BatchUpdateView.as_view()
176+
177+
147178
class DownloadRadiusBatchPdfView(ThrottledAPIMixin, DispatchOrgMixin, RetrieveAPIView):
148179
authentication_classes = (BearerAuthentication, SessionAuthentication)
149180
permission_classes = (IsOrganizationManager, IsAdminUser, DjangoModelPermissions)

openwisp_radius/tests/test_admin.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1494,3 +1494,33 @@ def test_admin_menu_groups(self):
14941494
with self.subTest("test radius group is registered"):
14951495
html = '<div class="mg-dropdown-label">RADIUS </div>'
14961496
self.assertContains(response, html, html=True)
1497+
1498+
def test_radiusbatch_organization_readonly_for_existing_objects(self):
1499+
"""
1500+
Test that organization field is readonly for existing RadiusBatch objects
1501+
"""
1502+
batch = self._create_radius_batch(
1503+
name="test-batch",
1504+
strategy="prefix",
1505+
prefix="test-prefix"
1506+
)
1507+
url = reverse(f"admin:{self.app_label}_radiusbatch_change", args=[batch.pk])
1508+
1509+
response = self.client.get(url)
1510+
self.assertEqual(response.status_code, 200)
1511+
1512+
self.assertContains(response, 'readonly')
1513+
self.assertContains(response, batch.organization.name)
1514+
1515+
def test_radiusbatch_organization_editable_for_new_objects(self):
1516+
"""
1517+
Test that organization field is editable for new RadiusBatch objects
1518+
"""
1519+
url = reverse(f"admin:{self.app_label}_radiusbatch_add")
1520+
1521+
response = self.client.get(url)
1522+
self.assertEqual(response.status_code, 200)
1523+
1524+
self.assertContains(response, 'name="organization"')
1525+
form_html = response.content.decode()
1526+
self.assertIn('name="organization"', form_html)

openwisp_radius/tests/test_api/test_api.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1053,6 +1053,111 @@ def test_user_group_check_serializer_counter_does_not_exist(self):
10531053
},
10541054
)
10551055

1056+
def _get_admin_auth_header(self):
1057+
"""Helper method to get admin authentication header"""
1058+
login_payload = {"username": "admin", "password": "tester"}
1059+
login_url = reverse("radius:user_auth_token", args=[self.default_org.slug])
1060+
login_response = self.client.post(login_url, data=login_payload)
1061+
return f"Bearer {login_response.json()['key']}"
1062+
1063+
def test_batch_update_organization_readonly(self):
1064+
"""
1065+
Test that organization field is readonly when updating RadiusBatch objects
1066+
"""
1067+
data = self._radius_batch_prefix_data()
1068+
response = self._radius_batch_post_request(data)
1069+
self.assertEqual(response.status_code, 201)
1070+
batch = RadiusBatch.objects.get()
1071+
original_org = batch.organization
1072+
1073+
new_org = self._create_org(**{"name": "new-org", "slug": "new-org"})
1074+
1075+
header = self._get_admin_auth_header()
1076+
1077+
url = reverse("radius:batch_detail", args=[batch.pk])
1078+
update_data = {
1079+
"name": "updated-batch-name",
1080+
"organization": str(new_org.pk),
1081+
}
1082+
response = self.client.patch(
1083+
url,
1084+
json.dumps(update_data),
1085+
HTTP_AUTHORIZATION=header,
1086+
content_type="application/json"
1087+
)
1088+
self.assertEqual(response.status_code, 200)
1089+
1090+
batch.refresh_from_db()
1091+
self.assertEqual(batch.organization, original_org)
1092+
self.assertEqual(batch.name, "updated-batch-name")
1093+
1094+
def test_batch_retrieve_and_update_api(self):
1095+
"""
1096+
Test retrieving and updating RadiusBatch objects via API
1097+
"""
1098+
data = self._radius_batch_prefix_data()
1099+
response = self._radius_batch_post_request(data)
1100+
self.assertEqual(response.status_code, 201)
1101+
batch = RadiusBatch.objects.get()
1102+
1103+
header = self._get_admin_auth_header()
1104+
1105+
url = reverse("radius:batch_detail", args=[batch.pk])
1106+
response = self.client.get(url, HTTP_AUTHORIZATION=header)
1107+
self.assertEqual(response.status_code, 200)
1108+
self.assertEqual(response.data["name"], batch.name)
1109+
self.assertEqual(str(response.data["organization"]), str(batch.organization.pk))
1110+
1111+
update_data = {
1112+
"name": "updated-batch-name",
1113+
"strategy": "prefix",
1114+
"prefix": batch.prefix,
1115+
"organization_slug": batch.organization.slug,
1116+
}
1117+
response = self.client.put(
1118+
url,
1119+
json.dumps(update_data),
1120+
HTTP_AUTHORIZATION=header,
1121+
content_type="application/json"
1122+
)
1123+
self.assertEqual(response.status_code, 200)
1124+
batch.refresh_from_db()
1125+
self.assertEqual(batch.name, "updated-batch-name")
1126+
1127+
def test_batch_update_permissions(self):
1128+
"""
1129+
Test that proper permissions are required for updating RadiusBatch objects
1130+
"""
1131+
data = self._radius_batch_prefix_data()
1132+
response = self._radius_batch_post_request(data)
1133+
self.assertEqual(response.status_code, 201)
1134+
batch = RadiusBatch.objects.get()
1135+
1136+
url = reverse("radius:batch_detail", args=[batch.pk])
1137+
1138+
response = self.client.patch(url, {"name": "new-name"})
1139+
self.assertEqual(response.status_code, 401)
1140+
1141+
user = self._get_user()
1142+
user_token = Token.objects.create(user=user)
1143+
header = f"Bearer {user_token.key}"
1144+
response = self.client.patch(
1145+
url,
1146+
json.dumps({"name": "new-name"}),
1147+
HTTP_AUTHORIZATION=header,
1148+
content_type="application/json"
1149+
)
1150+
self.assertEqual(response.status_code, 403)
1151+
1152+
header = self._get_admin_auth_header()
1153+
response = self.client.patch(
1154+
url,
1155+
json.dumps({"name": "new-name"}),
1156+
HTTP_AUTHORIZATION=header,
1157+
content_type="application/json"
1158+
)
1159+
self.assertEqual(response.status_code, 200)
1160+
10561161

10571162
class TestTransactionApi(AcctMixin, ApiTokenMixin, BaseTransactionTestCase):
10581163
def test_user_radius_usage_view(self):

0 commit comments

Comments
 (0)