Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion docs/user/rest-api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -820,7 +820,7 @@ This API endpoint allows to use the features described in
This API endpoint allows to use the features described in
:doc:`importing_users` and :doc:`generating_users`.

Responds only to **POST**, used to save a ``RadiusBatch`` instance.
Responds only to **POST**, used to create a ``RadiusBatch`` instance.

It is possible to generate the users of the ``RadiusBatch`` with two
different strategies: csv or prefix.
Expand Down Expand Up @@ -856,6 +856,35 @@ When using this strategy, in the response you can find the field
``pdf_link`` which can be used to download a PDF file containing the user
credentials.

Batch retrieve and update
+++++++++++++++++++++++++

.. code-block:: text

/api/v1/radius/batch/<id>/

Responds to **GET**, **PUT**, and **PATCH** methods.

Used to retrieve or update a ``RadiusBatch`` instance.

.. note::

The ``organization`` field is **read-only** for existing batch objects
and cannot be changed via the API. This is intentional as changing the
organization after batch creation would be inconsistent.

Parameters for **GET**:

===== =================
Param Description
===== =================
id UUID of the batch
===== =================

The ``organization`` field is the only field that is explicitly
**read-only** and cannot be updated via this endpoint. All other editable
fields can be modified through **PUT** or **PATCH** requests.
Comment on lines +876 to +886
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Documentation could be more specific about updatable fields.

The documentation states that organization is read-only but implies "all other editable fields" can be modified. However, per the admin implementation and likely serializer constraints, fields like strategy, csvfile, prefix, users, and status may also be read-only after creation (especially for non-pending batches). Consider adding a parameter table for PUT/PATCH similar to the POST section, clarifying which fields are actually updatable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/user/rest-api.rst` around lines 876 - 886, Update the REST API docs for
the batch GET/PUT/PATCH endpoints to explicitly list which fields are updatable:
add a parameter table for PUT/PATCH (similar to the POST table) that names each
field (organization, strategy, csvfile, prefix, users, status, id, etc.) and
marks whether it is read-only, write-on-create-only, or writable (and any
conditions, e.g., status/strategy/csvfile/users immutable once batch is not
pending). Reference the same field names used in the API/serializer
(organization, strategy, csvfile, prefix, users, status) and document allowed
transitions or constraints (e.g., status changes only via admin actions or when
batch is pending) so PATCH/PUT behavior is unambiguous.


Batch CSV Download
++++++++++++++++++

Expand Down
8 changes: 3 additions & 5 deletions openwisp_radius/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -463,23 +463,21 @@ def delete_selected_batches(self, request, queryset):
)

def get_readonly_fields(self, request, obj=None):
readonly_fields = super(RadiusBatchAdmin, self).get_readonly_fields(
request, obj
)
readonly_fields = super().get_readonly_fields(request, obj)
if obj and obj.status != "pending":
return (
"strategy",
"organization",
"prefix",
"csvfile",
"number_of_users",
"users",
"expiration_date",
"name",
"organization",
"status",
) + readonly_fields
elif obj:
return ("status",) + readonly_fields
return ("organization", "status") + readonly_fields
return ("status",) + readonly_fields

def has_delete_permission(self, request, obj=None):
Expand Down
48 changes: 48 additions & 0 deletions openwisp_radius/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,54 @@ class Meta:
read_only_fields = ("status", "user_credentials", "created", "modified")


class RadiusBatchUpdateSerializer(RadiusBatchSerializer):
"""
Serializer for updating RadiusBatch objects.
Makes the organization field readonly for existing objects.
"""

organization_slug = RadiusOrganizationField(
help_text=("Slug of the organization for creating radius batch."),
required=False,
label="organization",
slug_field="slug",
write_only=True,
)

def validate(self, data):
"""
Validates partial updates while preserving model-level validation.
Ignores organization_slug if provided since organization is readonly.
"""
data.pop("organization_slug", None)
strategy = data.get("strategy") or (self.instance and self.instance.strategy)
if (
strategy == "prefix"
and "number_of_users" in data
and not data.get("number_of_users")
):
raise serializers.ValidationError(
{"number_of_users": _("The field number_of_users cannot be empty")}
)
validated_data = serializers.ModelSerializer.validate(self, data)
# Run model-level validation (full_clean) for update
if self.instance:
batch_data = {
field: validated_data.get(field, getattr(self.instance, field))
for field in validated_data
}
batch_data.pop("number_of_users", None)
for field, value in batch_data.items():
setattr(self.instance, field, value)
self.instance.full_clean()
return validated_data

class Meta:
model = RadiusBatch
fields = "__all__"
read_only_fields = ("created", "modified", "user_credentials", "organization")
Comment on lines +560 to +563
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check admin readonly fields vs serializer readonly fields
echo "=== Admin readonly fields for non-pending batches ==="
rg -n "strategy|organization|prefix|csvfile|number_of_users|users|expiration_date|name|status" openwisp_radius/admin.py | grep -A2 -B2 "get_readonly"

echo ""
echo "=== Serializer read_only_fields ==="
rg -n "read_only_fields" openwisp_radius/api/serializers.py

Repository: openwisp/openwisp-radius

Length of output: 548


🏁 Script executed:

#!/bin/bash
# Search for RadiusBatchAdmin and get_readonly_fields
echo "=== Search for RadiusBatchAdmin class ==="
rg -A 30 "class RadiusBatchAdmin" openwisp_radius/admin.py

echo ""
echo "=== Search for get_readonly_fields method ==="
rg -B 5 -A 20 "def get_readonly_fields" openwisp_radius/admin.py | head -100

Repository: openwisp/openwisp-radius

Length of output: 1837


🏁 Script executed:

#!/bin/bash
# Find RadiusBatchUpdateSerializer and check for status handling
echo "=== RadiusBatchUpdateSerializer full implementation ==="
rg -B 50 "class RadiusBatchUpdateSerializer" openwisp_radius/api/serializers.py | tail -60

echo ""
echo "=== Check validate method for status handling ==="
rg -A 40 "def validate" openwisp_radius/api/serializers.py | grep -A 40 "class RadiusBatchUpdateSerializer" | tail -45

Repository: openwisp/openwisp-radius

Length of output: 3563


🏁 Script executed:

#!/bin/bash
# Check RadiusBatch model for clean() method
echo "=== RadiusBatch model clean() method ==="
rg -B 5 -A 30 "def clean" openwisp_radius/models.py | grep -A 35 "class RadiusBatch" | tail -50

echo ""
echo "=== Check RadiusBatch status field and related logic ==="
rg -B 3 -A 8 "status.*=" openwisp_radius/models.py | grep -A 10 -B 3 "RadiusBatch\|pending\|COMPLETED" | head -60

Repository: openwisp/openwisp-radius

Length of output: 166


🏁 Script executed:

#!/bin/bash
# Find RadiusBatch model
echo "=== Find RadiusBatch model ==="
fd "models.py" openwisp_radius/ | head -5

echo ""
echo "=== Look for RadiusBatch class definition ==="
rg -A 50 "class RadiusBatch" openwisp_radius/models.py | head -80

echo ""
echo "=== Check if there's a clean method anywhere in the file ==="
rg "def clean" openwisp_radius/models.py

Repository: openwisp/openwisp-radius

Length of output: 1272


🏁 Script executed:

#!/bin/bash
# Find AbstractRadiusBatch and check for clean() method
echo "=== Look for AbstractRadiusBatch class ==="
rg -B 5 -A 100 "class AbstractRadiusBatch" openwisp_radius/base/models.py | head -150

echo ""
echo "=== Check for clean method ==="
rg -B 3 -A 20 "def clean" openwisp_radius/base/models.py | grep -A 22 -B 3 "RadiusBatch"

Repository: openwisp/openwisp-radius

Length of output: 3419


🏁 Script executed:

#!/bin/bash
# Get the full clean method from AbstractRadiusBatch
rg -A 40 "def clean\(self\):" openwisp_radius/base/models.py

Repository: openwisp/openwisp-radius

Length of output: 9623


read_only_fields does not match admin readonly behavior for non-pending batches.

The admin's get_readonly_fields makes strategy, prefix, csvfile, number_of_users, users, expiration_date, name, and status all readonly for non-pending batches. However, this serializer only marks organization as readonly (plus the standard created, modified, user_credentials). The model's clean() method does not enforce this restriction either.

This creates an inconsistency: the admin prevents editing these fields for completed batches, but the API allows it. Consider making these fields conditionally readonly based on batch status to match admin behavior, or document this difference explicitly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openwisp_radius/api/serializers.py` around lines 560 - 563, The serializer's
Meta.read_only_fields currently only includes ("created", "modified",
"user_credentials", "organization") which allows API edits that the admin UI
forbids for non-pending RadiusBatch records; update the RadiusBatch serializer
(referencing the Meta class and the RadiusBatch model) to block changes to the
same fields the admin makes readonly for non-pending batches — strategy, prefix,
csvfile, number_of_users, users, expiration_date, name, and status — by
overriding the serializer's update() (and optionally validate()/create() if
needed) to check instance.status (compare against the Pending status constant on
RadiusBatch) and either remove those keys from validated_data or raise a
ValidationError when an attempt is made to change them for non-pending batches,
leaving organization and the existing created/modified/user_credentials
read-only as before.


Comment on lines +560 to +564
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Cross-check serializer mutability vs documented read-only contract
rg -n -C3 "class RadiusBatchUpdateSerializer|read_only_fields" openwisp_radius/api/serializers.py
rg -n -C3 "Parameters for \\*\\*PUT\\*\\*/\\*\\*PATCH\\*\\*|Fields that are \\*\\*read-only\\*\\*" docs/user/rest-api.rst

Repository: openwisp/openwisp-radius

Length of output: 2179


🏁 Script executed:

# Get full context of RadiusBatch API documentation
sed -n '880,920p' docs/user/rest-api.rst

Repository: openwisp/openwisp-radius

Length of output: 1424


🏁 Script executed:

# Check RadiusBatch model definition and fields
rg -n "class RadiusBatch" openwisp_radius/models.py -A 50

Repository: openwisp/openwisp-radius

Length of output: 1129


🏁 Script executed:

# Verify the parent RadiusBatchSerializer definition to understand inheritance
sed -n '512,520p' openwisp_radius/api/serializers.py

Repository: openwisp/openwisp-radius

Length of output: 336


RadiusBatchUpdateSerializer's read_only_fields is incomplete and does not enforce the documented API contract.

The serializer declares only ("created", "modified", "user_credentials", "organization") as read-only, but the API documentation specifies that strategy, csvfile, prefix, and users are also immutable after creation. This mismatch allows these fields to remain writable at the serializer level, contradicting the documented behavior.

Suggested fix
     class Meta:
         model = RadiusBatch
         fields = "__all__"
-        read_only_fields = ("created", "modified", "user_credentials", "organization")
+        read_only_fields = (
+            "created",
+            "modified",
+            "user_credentials",
+            "organization",
+            "strategy",
+            "csvfile",
+            "prefix",
+            "users",
+            "status",
+        )
📝 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
class Meta:
model = RadiusBatch
fields = "__all__"
read_only_fields = ("created", "modified", "user_credentials", "organization")
class Meta:
model = RadiusBatch
fields = "__all__"
read_only_fields = (
"created",
"modified",
"user_credentials",
"organization",
"strategy",
"csvfile",
"prefix",
"users",
"status",
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openwisp_radius/api/serializers.py` around lines 554 - 558,
RadiusBatchUpdateSerializer currently declares Meta.read_only_fields as
("created", "modified", "user_credentials", "organization") but must also mark
"strategy", "csvfile", "prefix", and "users" read-only to match the API
contract; update RadiusBatchUpdateSerializer.Meta to include those four fields
in the read_only_fields tuple (e.g., ("created", "modified", "user_credentials",
"organization", "strategy", "csvfile", "prefix", "users")) so the serializer
enforces immutability after creation.


class PasswordResetSerializer(BasePasswordResetSerializer):
input = serializers.CharField()
email = None
Expand Down
3 changes: 3 additions & 0 deletions openwisp_radius/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ def get_api_urls(api_views=None):
name="phone_number_change",
),
path("radius/batch/", api_views.batch, name="batch"),
path(
"radius/batch/<uuid:pk>/", api_views.batch_detail, name="batch_detail"
),
path(
"radius/organization/<slug:slug>/batch/<uuid:pk>/pdf/",
api_views.download_rad_batch_pdf,
Expand Down
30 changes: 30 additions & 0 deletions openwisp_radius/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
ListAPIView,
ListCreateAPIView,
RetrieveAPIView,
RetrieveUpdateAPIView,
RetrieveUpdateDestroyAPIView,
)
from rest_framework.pagination import PageNumberPagination
Expand Down Expand Up @@ -72,6 +73,7 @@
ChangePhoneNumberSerializer,
RadiusAccountingSerializer,
RadiusBatchSerializer,
RadiusBatchUpdateSerializer,
RadiusGroupSerializer,
RadiusUserGroupSerializer,
UserRadiusUsageSerializer,
Expand Down Expand Up @@ -157,6 +159,34 @@ def validate_membership(self, user):
raise serializers.ValidationError({"non_field_errors": [message]})


class BatchUpdateView(ThrottledAPIMixin, RetrieveUpdateAPIView):
"""
API view for updating existing RadiusBatch objects.
Organization field is readonly for existing objects.
"""

authentication_classes = (BearerAuthentication, SessionAuthentication)
permission_classes = (IsOrganizationManager, IsAdminUser, DjangoModelPermissions)
queryset = RadiusBatch.objects.none()
serializer_class = RadiusBatchSerializer

def get_queryset(self):
"""Filter batches by user's organization membership"""
user = self.request.user
if user.is_superuser:
return RadiusBatch.objects.all()
return RadiusBatch.objects.filter(organization__in=user.organizations_managed)

def get_serializer_class(self):
"""Use RadiusBatchUpdateSerializer for PUT/PATCH requests"""
if self.request.method in ("PUT", "PATCH"):
return RadiusBatchUpdateSerializer
return RadiusBatchSerializer


batch_detail = BatchUpdateView.as_view()


class DownloadRadiusBatchPdfView(ThrottledAPIMixin, DispatchOrgMixin, RetrieveAPIView):
authentication_classes = (BearerAuthentication, SessionAuthentication)
permission_classes = (IsOrganizationManager, IsAdminUser, DjangoModelPermissions)
Expand Down
106 changes: 106 additions & 0 deletions openwisp_radius/tests/test_api/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1556,6 +1556,112 @@ def test_radius_user_group_serializer_without_view_context(self):
self.assertEqual(serializer._user, None)
self.assertEqual(serializer.fields["group"].queryset.count(), 0)

def _get_admin_auth_header(self):
"""Helper method to get admin authentication header"""
login_payload = {"username": "admin", "password": "tester"}
login_url = reverse("radius:user_auth_token", args=[self.default_org.slug])
login_response = self.client.post(login_url, data=login_payload)
self.assertEqual(login_response.status_code, status.HTTP_200_OK)
return f"Bearer {login_response.json()['key']}"
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

_get_admin_auth_header assumes the login request succeeds and returns JSON with a key. If authentication fails, this will raise a KeyError and obscure the real failure. Consider asserting login_response.status_code == 200 (and/or checking the presence of key) to make failures clearer.

Suggested change
return f"Bearer {login_response.json()['key']}"
self.assertEqual(login_response.status_code, status.HTTP_200_OK)
login_response_data = login_response.json()
self.assertIn("key", login_response_data)
return f"Bearer {login_response_data['key']}"

Copilot uses AI. Check for mistakes.
Comment on lines +1559 to +1565
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Potential issue: _get_admin() not called before authentication.

The helper method authenticates as "admin"/"tester" but doesn't call self._get_admin() to ensure the admin user exists first. This works in the current tests because they all call _radius_batch_post_request() before _get_admin_auth_header(), which internally creates the admin user. However, if this helper is used in future tests without that precondition, it will fail with a 400 response.

Consider making the helper self-contained:

💡 Suggested improvement
 def _get_admin_auth_header(self):
     """Helper method to get admin authentication header"""
+    self._get_admin()  # Ensure admin user exists
     login_payload = {"username": "admin", "password": "tester"}
     login_url = reverse("radius:user_auth_token", args=[self.default_org.slug])
     login_response = self.client.post(login_url, data=login_payload)
     self.assertEqual(login_response.status_code, status.HTTP_200_OK)
     return f"Bearer {login_response.json()['key']}"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openwisp_radius/tests/test_api/test_api.py` around lines 1559 - 1565, The
_get_admin_auth_header helper assumes an admin exists; make it self-contained by
ensuring the admin user is created before attempting login: call
self._get_admin() (or otherwise create the admin) at the start of
_get_admin_auth_header so the POST to reverse("radius:user_auth_token",
args=[self.default_org.slug]) never fails with 400; keep the rest of the logic
(login_payload, client.post, status check, return token) intact and do not rely
on _radius_batch_post_request being called by callers.


def test_batch_update_organization_readonly(self):
"""
Test that organization field is readonly when updating RadiusBatch objects
"""
data = self._radius_batch_prefix_data()
response = self._radius_batch_post_request(data)
self.assertEqual(response.status_code, 201)
batch = RadiusBatch.objects.get()
original_org = batch.organization

new_org = self._create_org(**{"name": "new-org", "slug": "new-org"})

header = self._get_admin_auth_header()

url = reverse("radius:batch_detail", args=[batch.pk])
update_data = {
"name": "updated-batch-name",
"organization": str(new_org.pk),
}
response = self.client.patch(
url,
json.dumps(update_data),
HTTP_AUTHORIZATION=header,
content_type="application/json",
)
self.assertEqual(response.status_code, 200)

batch.refresh_from_db()
self.assertEqual(batch.organization, original_org)
self.assertEqual(batch.name, "updated-batch-name")

def test_batch_retrieve_and_update_api(self):
"""
Test retrieving and updating RadiusBatch objects via API
"""
data = self._radius_batch_prefix_data()
response = self._radius_batch_post_request(data)
self.assertEqual(response.status_code, 201)
batch = RadiusBatch.objects.get()

header = self._get_admin_auth_header()

url = reverse("radius:batch_detail", args=[batch.pk])
response = self.client.get(url, HTTP_AUTHORIZATION=header)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["name"], batch.name)
self.assertEqual(str(response.data["organization"]), str(batch.organization.pk))

update_data = {
"name": "updated-batch-name",
"strategy": "prefix",
"prefix": batch.prefix,
"organization_slug": batch.organization.slug,
}
response = self.client.put(
url,
json.dumps(update_data),
HTTP_AUTHORIZATION=header,
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
batch.refresh_from_db()
self.assertEqual(batch.name, "updated-batch-name")

def test_batch_update_permissions(self):
"""
Test that proper permissions are required for updating RadiusBatch objects
"""
data = self._radius_batch_prefix_data()
response = self._radius_batch_post_request(data)
self.assertEqual(response.status_code, 201)
batch = RadiusBatch.objects.get()

url = reverse("radius:batch_detail", args=[batch.pk])

response = self.client.patch(url, {"name": "new-name"})
self.assertEqual(response.status_code, 401)

user = self._get_user()
user_token = Token.objects.create(user=user)
header = f"Bearer {user_token.key}"
response = self.client.patch(
url,
json.dumps({"name": "new-name"}),
HTTP_AUTHORIZATION=header,
content_type="application/json",
)
self.assertEqual(response.status_code, 403)

header = self._get_admin_auth_header()
response = self.client.patch(
url,
json.dumps({"name": "new-name"}),
HTTP_AUTHORIZATION=header,
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
Comment on lines +1631 to +1663
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider adding cross-organization permission test.

The permission tests cover unauthenticated, unprivileged user, and admin access. Consider adding a test case for an organization manager who has permissions but tries to update a batch belonging to a different organization they don't manage - this would verify the queryset filtering in BatchUpdateView.get_queryset().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openwisp_radius/tests/test_api/test_api.py` around lines 1631 - 1663, Add a
cross-organization case to test_batch_update_permissions: create a second
Organization and a RadiusBatch that belongs to that other organization, then
authenticate as an organization manager who has permissions only for the first
org (use whatever helper you have for manager auth, e.g.
_get_org_manager_auth_header or create a manager user and Token) and attempt to
PATCH the other-org batch URL; assert the response is 403 to validate
BatchUpdateView.get_queryset() filtering. Ensure you reference the existing test
method test_batch_update_permissions, the RadiusBatch instance for the other
organization, and BatchUpdateView.get_queryset() behavior when adding this
assertion.



class TestTransactionApi(AcctMixin, ApiTokenMixin, BaseTransactionTestCase):
def test_user_radius_usage_view(self):
Expand Down
6 changes: 6 additions & 0 deletions tests/openwisp2/sample_radius/api/views.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from openwisp_radius.api.freeradius_views import AccountingView as BaseAccountingView
from openwisp_radius.api.freeradius_views import AuthorizeView as BaseAuthorizeView
from openwisp_radius.api.freeradius_views import PostAuthView as BasePostAuthView
from openwisp_radius.api.views import BatchUpdateView as BaseBatchUpdateView
from openwisp_radius.api.views import BatchView as BaseBatchView
from openwisp_radius.api.views import ChangePhoneNumberView as BaseChangePhoneNumberView
from openwisp_radius.api.views import CreatePhoneTokenView as BaseCreatePhoneTokenView
Expand Down Expand Up @@ -104,6 +105,10 @@ class RadiusAccountingView(BaseRadiusAccountingView):
pass


class BatchUpdateView(BaseBatchUpdateView):
pass


authorize = AuthorizeView.as_view()
postauth = PostAuthView.as_view()
accounting = AccountingView.as_view()
Expand All @@ -122,6 +127,7 @@ class RadiusAccountingView(BaseRadiusAccountingView):
change_phone_number = ChangePhoneNumberView.as_view()
download_rad_batch_pdf = DownloadRadiusBatchPdfView.as_view()
radius_accounting = RadiusAccountingView.as_view()
batch_detail = BatchUpdateView.as_view()
radius_group_list = RadiusGroupListView.as_view()
radius_group_detail = RadiusGroupDetailView.as_view()
radius_user_group_list = RadiusUserGroupListCreateView.as_view()
Expand Down
Loading