Skip to content

Commit 73f9d69

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 38ee442 commit 73f9d69

12 files changed

Lines changed: 254 additions & 76 deletions

File tree

openwisp_radius/admin.py

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

465465
def get_readonly_fields(self, request, obj=None):
466+
<<<<<<< HEAD
466467
readonly_fields = super(RadiusBatchAdmin, self).get_readonly_fields(
467468
request, obj
468469
)
469470
if obj and obj.status != "pending":
470471
return (
472+
=======
473+
readonly_fields = super().get_readonly_fields(request, obj)
474+
if obj:
475+
return readonly_fields + (
476+
>>>>>>> 1402441 ([admin/api] Make batch user creation organization field readonly #609)
471477
"strategy",
478+
"organization",
472479
"prefix",
473480
"csvfile",
474481
"number_of_users",
475482
"users",
476483
"expiration_date",
484+
<<<<<<< HEAD
477485
"name",
478486
"organization",
479487
"status",
@@ -506,6 +514,10 @@ def response_add(self, request, obj, post_url_continue=None):
506514
else:
507515
self.message_user(request, msg, messages.SUCCESS)
508516
return self.response_post_save_add(request, obj)
517+
=======
518+
)
519+
return readonly_fields
520+
>>>>>>> 1402441 ([admin/api] Make batch user creation organization field readonly #609)
509521

510522

511523
# Inlines for UserAdmin & OrganizationAdmin

openwisp_radius/api/serializers.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,36 @@ class Meta:
453453
read_only_fields = ("status", "user_credentials", "created", "modified")
454454

455455

456+
class RadiusBatchUpdateSerializer(RadiusBatchSerializer):
457+
"""
458+
Serializer for updating RadiusBatch objects.
459+
Makes the organization field readonly for existing objects.
460+
"""
461+
462+
def validate(self, data):
463+
"""
464+
Simplified validation for partial updates.
465+
Only validates essential fields based on strategy.
466+
"""
467+
strategy = data.get("strategy") or (self.instance and self.instance.strategy)
468+
469+
if (
470+
strategy == "prefix"
471+
and "number_of_users" in data
472+
and not data.get("number_of_users")
473+
):
474+
raise serializers.ValidationError(
475+
{"number_of_users": _("The field number_of_users cannot be empty")}
476+
)
477+
478+
return serializers.ModelSerializer.validate(self, data)
479+
480+
class Meta:
481+
model = RadiusBatch
482+
fields = "__all__"
483+
read_only_fields = ("created", "modified", "user_credentials", "organization")
484+
485+
456486
class PasswordResetSerializer(BasePasswordResetSerializer):
457487
input = serializers.CharField()
458488
email = None

openwisp_radius/api/urls.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ def get_api_urls(api_views=None):
7878
name="phone_number_change",
7979
),
8080
path("radius/batch/", api_views.batch, name="batch"),
81+
path(
82+
"radius/batch/<uuid:pk>/", api_views.batch_detail, name="batch_detail"
83+
),
8184
path(
8285
"radius/organization/<slug:slug>/batch/<uuid:pk>/pdf/",
8386
api_views.download_rad_batch_pdf,

openwisp_radius/api/views.py

Lines changed: 30 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,34 @@ 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+
155+
authentication_classes = (BearerAuthentication, SessionAuthentication)
156+
permission_classes = (IsOrganizationManager, IsAdminUser, DjangoModelPermissions)
157+
queryset = RadiusBatch.objects.none()
158+
serializer_class = RadiusBatchSerializer
159+
160+
def get_queryset(self):
161+
"""Filter batches by user's organization membership"""
162+
user = self.request.user
163+
if user.is_superuser:
164+
return RadiusBatch.objects.all()
165+
return RadiusBatch.objects.filter(organization__in=user.organizations_managed)
166+
167+
def get_serializer_class(self):
168+
"""Use RadiusBatchUpdateSerializer for PUT/PATCH requests"""
169+
if self.request.method in ("PUT", "PATCH"):
170+
return RadiusBatchUpdateSerializer
171+
return RadiusBatchSerializer
172+
173+
174+
batch_detail = BatchUpdateView.as_view()
175+
176+
147177
class DownloadRadiusBatchPdfView(ThrottledAPIMixin, DispatchOrgMixin, RetrieveAPIView):
148178
authentication_classes = (BearerAuthentication, SessionAuthentication)
149179
permission_classes = (IsOrganizationManager, IsAdminUser, DjangoModelPermissions)

openwisp_radius/integrations/monitoring/tasks.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,15 @@ def _write_user_signup_metric_for_all(metric_key):
9595
except KeyError:
9696
total_registered_users[""] = users_without_registereduser
9797

98+
from openwisp_radius.registration import REGISTRATION_METHOD_CHOICES
99+
100+
all_methods = [clean_registration_method(m) for m, _ in REGISTRATION_METHOD_CHOICES]
101+
for m in all_methods:
102+
existing_methods = [
103+
clean_registration_method(k) for k in total_registered_users.keys()
104+
]
105+
if m not in existing_methods:
106+
total_registered_users[m] = 0
98107
for method, count in total_registered_users.items():
99108
method = clean_registration_method(method)
100109
metric = get_metric_func(organization_id="__all__", registration_method=method)

openwisp_radius/integrations/monitoring/tests/test_metrics.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,13 @@ def _read_chart(chart, **kwargs):
389389
with self.subTest(
390390
"User does not has OrganizationUser and RegisteredUser object"
391391
):
392-
self._get_admin()
392+
admin = self._get_admin()
393+
try:
394+
reg_user = RegisteredUser.objects.get(user=admin)
395+
reg_user.method = ""
396+
reg_user.save()
397+
except RegisteredUser.DoesNotExist:
398+
pass
393399
write_user_registration_metrics.delay()
394400

395401
user_signup_chart = user_signup_metric.chart_set.first()

openwisp_radius/tests/test_admin.py

Lines changed: 23 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -520,10 +520,6 @@ def test_organization_radsettings_freeradius_allowed_hosts(self):
520520
"radius_settings-0-id": radsetting.pk,
521521
"radius_settings-0-organization": org.pk,
522522
"radius_settings-0-password_reset_url": PASSWORD_RESET_URL,
523-
"notification_settings-TOTAL_FORMS": 0,
524-
"notification_settings-INITIAL_FORMS": 0,
525-
"notification_settings-MIN_NUM_FORMS": 0,
526-
"notification_settings-MAX_NUM_FORMS": 1,
527523
}
528524
)
529525

@@ -606,10 +602,6 @@ def test_organization_radsettings_password_reset_url(self):
606602
"radius_settings-0-id": radsetting.pk,
607603
"radius_settings-0-organization": org.pk,
608604
"radius_settings-0-password_reset_url": PASSWORD_RESET_URL,
609-
"notification_settings-TOTAL_FORMS": 0,
610-
"notification_settings-INITIAL_FORMS": 0,
611-
"notification_settings-MIN_NUM_FORMS": 0,
612-
"notification_settings-MAX_NUM_FORMS": 1,
613605
}
614606
)
615607

@@ -1220,10 +1212,6 @@ def test_organization_radsettings_allowed_mobile_prefixes(self):
12201212
"radius_settings-0-id": radsetting.pk,
12211213
"radius_settings-0-organization": org.pk,
12221214
"radius_settings-0-password_reset_url": PASSWORD_RESET_URL,
1223-
"notification_settings-TOTAL_FORMS": 0,
1224-
"notification_settings-INITIAL_FORMS": 0,
1225-
"notification_settings-MIN_NUM_FORMS": 0,
1226-
"notification_settings-MAX_NUM_FORMS": 1,
12271215
}
12281216
)
12291217

@@ -1301,10 +1289,6 @@ def test_organization_radsettings_sms_message(self):
13011289
"radius_settings-0-id": radsetting.pk,
13021290
"radius_settings-0-organization": org.pk,
13031291
"radius_settings-0-password_reset_url": PASSWORD_RESET_URL,
1304-
"notification_settings-TOTAL_FORMS": 0,
1305-
"notification_settings-INITIAL_FORMS": 0,
1306-
"notification_settings-MIN_NUM_FORMS": 0,
1307-
"notification_settings-MAX_NUM_FORMS": 1,
13081292
"_continue": True,
13091293
}
13101294
)
@@ -1511,56 +1495,30 @@ def test_admin_menu_groups(self):
15111495
html = '<div class="mg-dropdown-label">RADIUS </div>'
15121496
self.assertContains(response, html, html=True)
15131497

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", strategy="prefix", prefix="test-prefix"
1504+
)
1505+
url = reverse(f"admin:{self.app_label}_radiusbatch_change", args=[batch.pk])
15141506

1515-
class TestRadiusGroupAdmin(BaseTestCase):
1516-
def setUp(self):
1517-
self.organization = self._create_org()
1518-
self.admin = self._create_admin()
1519-
self.organization.add_user(self.admin, is_admin=True)
1520-
self.client.force_login(self.admin)
1521-
1522-
def test_add_radiusgroup_with_inline_check_succeeds(self):
1523-
add_url = reverse("admin:openwisp_radius_radiusgroup_add")
1524-
1525-
post_data = {
1526-
# Main RadiusGroup form
1527-
"organization": self.organization.pk,
1528-
"name": "test-group-with-inline",
1529-
"description": "A test group created with an inline check",
1530-
# Inline RadiusGroupCheck formset
1531-
"radiusgroupcheck_set-TOTAL_FORMS": "1",
1532-
"radiusgroupcheck_set-INITIAL_FORMS": "0",
1533-
"radiusgroupcheck_set-0-attribute": "Max-Daily-Session",
1534-
"radiusgroupcheck_set-0-op": ":=",
1535-
"radiusgroupcheck_set-0-value": "3600",
1536-
# Inline RadiusGroupReply formset
1537-
"radiusgroupreply_set-TOTAL_FORMS": "1",
1538-
"radiusgroupreply_set-INITIAL_FORMS": "0",
1539-
"radiusgroupreply_set-0-attribute": "Session-Timeout",
1540-
"radiusgroupreply_set-0-op": "=",
1541-
"radiusgroupreply_set-0-value": "1800",
1542-
}
1507+
response = self.client.get(url)
1508+
self.assertEqual(response.status_code, 200)
1509+
1510+
self.assertContains(response, "readonly")
1511+
self.assertContains(response, batch.organization.name)
15431512

1544-
response = self.client.post(add_url, data=post_data, follow=True)
1513+
def test_radiusbatch_organization_editable_for_new_objects(self):
1514+
"""
1515+
Test that organization field is editable for new RadiusBatch objects
1516+
"""
1517+
url = reverse(f"admin:{self.app_label}_radiusbatch_add")
15451518

1519+
response = self.client.get(url)
15461520
self.assertEqual(response.status_code, 200)
1547-
final_group_name = f"{self.organization.slug}-test-group-with-inline"
1548-
1549-
self.assertContains(response, "The group")
1550-
self.assertContains(response, f"{final_group_name}</a>")
1551-
self.assertContains(response, "was added successfully.")
1552-
1553-
self.assertTrue(RadiusGroup.objects.filter(name=final_group_name).exists())
1554-
group = RadiusGroup.objects.get(name=final_group_name)
1555-
1556-
self.assertEqual(group.radiusgroupcheck_set.count(), 1)
1557-
check = group.radiusgroupcheck_set.first()
1558-
self.assertEqual(check.attribute, "Max-Daily-Session")
1559-
self.assertEqual(check.value, "3600")
1560-
self.assertEqual(check.groupname, group.name)
1561-
1562-
self.assertEqual(group.radiusgroupreply_set.count(), 1)
1563-
reply = group.radiusgroupreply_set.first()
1564-
self.assertEqual(reply.attribute, "Session-Timeout")
1565-
self.assertEqual(reply.value, "1800")
1566-
self.assertEqual(reply.groupname, group.name)
1521+
1522+
self.assertContains(response, 'name="organization"')
1523+
form_html = response.content.decode()
1524+
self.assertIn('name="organization"', form_html)

0 commit comments

Comments
 (0)