Skip to content

Commit 7ad9c27

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 b512f3a commit 7ad9c27

12 files changed

Lines changed: 244 additions & 77 deletions

File tree

openwisp_radius/admin.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,7 @@ def get_readonly_fields(self, request, obj=None):
469469
if obj and obj.status != "pending":
470470
return (
471471
"strategy",
472+
"organization",
472473
"prefix",
473474
"csvfile",
474475
"number_of_users",
@@ -479,7 +480,7 @@ def get_readonly_fields(self, request, obj=None):
479480
"status",
480481
) + readonly_fields
481482
elif obj:
482-
return ("status",) + readonly_fields
483+
return ("organization", "status") + readonly_fields
483484
return ("status",) + readonly_fields
484485

485486
def has_delete_permission(self, request, obj=None):

openwisp_radius/api/serializers.py

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

517517

518+
class RadiusBatchUpdateSerializer(RadiusBatchSerializer):
519+
"""
520+
Serializer for updating RadiusBatch objects.
521+
Makes the organization field readonly for existing objects.
522+
"""
523+
524+
def validate(self, data):
525+
"""
526+
Simplified validation for partial updates.
527+
Only validates essential fields based on strategy.
528+
"""
529+
strategy = data.get("strategy") or (self.instance and self.instance.strategy)
530+
531+
if (
532+
strategy == "prefix"
533+
and "number_of_users" in data
534+
and not data.get("number_of_users")
535+
):
536+
raise serializers.ValidationError(
537+
{"number_of_users": _("The field number_of_users cannot be empty")}
538+
)
539+
540+
return serializers.ModelSerializer.validate(self, data)
541+
542+
class Meta:
543+
model = RadiusBatch
544+
fields = "__all__"
545+
read_only_fields = ("created", "modified", "user_credentials", "organization")
546+
547+
518548
class PasswordResetSerializer(BasePasswordResetSerializer):
519549
input = serializers.CharField()
520550
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
@@ -34,6 +34,7 @@
3434
ListAPIView,
3535
ListCreateAPIView,
3636
RetrieveAPIView,
37+
RetrieveUpdateAPIView,
3738
RetrieveUpdateDestroyAPIView,
3839
)
3940
from rest_framework.pagination import PageNumberPagination
@@ -72,6 +73,7 @@
7273
ChangePhoneNumberSerializer,
7374
RadiusAccountingSerializer,
7475
RadiusBatchSerializer,
76+
RadiusBatchUpdateSerializer,
7577
RadiusGroupSerializer,
7678
RadiusUserGroupSerializer,
7779
UserRadiusUsageSerializer,
@@ -157,6 +159,34 @@ def validate_membership(self, user):
157159
raise serializers.ValidationError({"non_field_errors": [message]})
158160

159161

162+
class BatchUpdateView(ThrottledAPIMixin, RetrieveUpdateAPIView):
163+
"""
164+
API view for updating existing RadiusBatch objects.
165+
Organization field is readonly for existing objects.
166+
"""
167+
168+
authentication_classes = (BearerAuthentication, SessionAuthentication)
169+
permission_classes = (IsOrganizationManager, IsAdminUser, DjangoModelPermissions)
170+
queryset = RadiusBatch.objects.none()
171+
serializer_class = RadiusBatchSerializer
172+
173+
def get_queryset(self):
174+
"""Filter batches by user's organization membership"""
175+
user = self.request.user
176+
if user.is_superuser:
177+
return RadiusBatch.objects.all()
178+
return RadiusBatch.objects.filter(organization__in=user.organizations_managed)
179+
180+
def get_serializer_class(self):
181+
"""Use RadiusBatchUpdateSerializer for PUT/PATCH requests"""
182+
if self.request.method in ("PUT", "PATCH"):
183+
return RadiusBatchUpdateSerializer
184+
return RadiusBatchSerializer
185+
186+
187+
batch_detail = BatchUpdateView.as_view()
188+
189+
160190
class DownloadRadiusBatchPdfView(ThrottledAPIMixin, DispatchOrgMixin, RetrieveAPIView):
161191
authentication_classes = (BearerAuthentication, SessionAuthentication)
162192
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)

openwisp_radius/tests/test_api/test_api.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1556,6 +1556,111 @@ def test_radius_user_group_serializer_without_view_context(self):
15561556
self.assertEqual(serializer._user, None)
15571557
self.assertEqual(serializer.fields["group"].queryset.count(), 0)
15581558

1559+
def _get_admin_auth_header(self):
1560+
"""Helper method to get admin authentication header"""
1561+
login_payload = {"username": "admin", "password": "tester"}
1562+
login_url = reverse("radius:user_auth_token", args=[self.default_org.slug])
1563+
login_response = self.client.post(login_url, data=login_payload)
1564+
return f"Bearer {login_response.json()['key']}"
1565+
1566+
def test_batch_update_organization_readonly(self):
1567+
"""
1568+
Test that organization field is readonly when updating RadiusBatch objects
1569+
"""
1570+
data = self._radius_batch_prefix_data()
1571+
response = self._radius_batch_post_request(data)
1572+
self.assertEqual(response.status_code, 201)
1573+
batch = RadiusBatch.objects.get()
1574+
original_org = batch.organization
1575+
1576+
new_org = self._create_org(**{"name": "new-org", "slug": "new-org"})
1577+
1578+
header = self._get_admin_auth_header()
1579+
1580+
url = reverse("radius:batch_detail", args=[batch.pk])
1581+
update_data = {
1582+
"name": "updated-batch-name",
1583+
"organization": str(new_org.pk),
1584+
}
1585+
response = self.client.patch(
1586+
url,
1587+
json.dumps(update_data),
1588+
HTTP_AUTHORIZATION=header,
1589+
content_type="application/json",
1590+
)
1591+
self.assertEqual(response.status_code, 200)
1592+
1593+
batch.refresh_from_db()
1594+
self.assertEqual(batch.organization, original_org)
1595+
self.assertEqual(batch.name, "updated-batch-name")
1596+
1597+
def test_batch_retrieve_and_update_api(self):
1598+
"""
1599+
Test retrieving and updating RadiusBatch objects via API
1600+
"""
1601+
data = self._radius_batch_prefix_data()
1602+
response = self._radius_batch_post_request(data)
1603+
self.assertEqual(response.status_code, 201)
1604+
batch = RadiusBatch.objects.get()
1605+
1606+
header = self._get_admin_auth_header()
1607+
1608+
url = reverse("radius:batch_detail", args=[batch.pk])
1609+
response = self.client.get(url, HTTP_AUTHORIZATION=header)
1610+
self.assertEqual(response.status_code, 200)
1611+
self.assertEqual(response.data["name"], batch.name)
1612+
self.assertEqual(str(response.data["organization"]), str(batch.organization.pk))
1613+
1614+
update_data = {
1615+
"name": "updated-batch-name",
1616+
"strategy": "prefix",
1617+
"prefix": batch.prefix,
1618+
"organization_slug": batch.organization.slug,
1619+
}
1620+
response = self.client.put(
1621+
url,
1622+
json.dumps(update_data),
1623+
HTTP_AUTHORIZATION=header,
1624+
content_type="application/json",
1625+
)
1626+
self.assertEqual(response.status_code, 200)
1627+
batch.refresh_from_db()
1628+
self.assertEqual(batch.name, "updated-batch-name")
1629+
1630+
def test_batch_update_permissions(self):
1631+
"""
1632+
Test that proper permissions are required for updating RadiusBatch objects
1633+
"""
1634+
data = self._radius_batch_prefix_data()
1635+
response = self._radius_batch_post_request(data)
1636+
self.assertEqual(response.status_code, 201)
1637+
batch = RadiusBatch.objects.get()
1638+
1639+
url = reverse("radius:batch_detail", args=[batch.pk])
1640+
1641+
response = self.client.patch(url, {"name": "new-name"})
1642+
self.assertEqual(response.status_code, 401)
1643+
1644+
user = self._get_user()
1645+
user_token = Token.objects.create(user=user)
1646+
header = f"Bearer {user_token.key}"
1647+
response = self.client.patch(
1648+
url,
1649+
json.dumps({"name": "new-name"}),
1650+
HTTP_AUTHORIZATION=header,
1651+
content_type="application/json",
1652+
)
1653+
self.assertEqual(response.status_code, 403)
1654+
1655+
header = self._get_admin_auth_header()
1656+
response = self.client.patch(
1657+
url,
1658+
json.dumps({"name": "new-name"}),
1659+
HTTP_AUTHORIZATION=header,
1660+
content_type="application/json",
1661+
)
1662+
self.assertEqual(response.status_code, 200)
1663+
15591664

15601665
class TestTransactionApi(AcctMixin, ApiTokenMixin, BaseTransactionTestCase):
15611666
def test_user_radius_usage_view(self):

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@ multi_line_output = 3
2020
use_parentheses = true
2121
include_trailing_comma = true
2222
force_grid_wrap = 0
23+

0 commit comments

Comments
 (0)