Skip to content
87 changes: 87 additions & 0 deletions docs/user/rest-api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -961,3 +961,90 @@ DELETE
^^^^^^

Deletes a RADIUS group identified by its UUID.

RADIUS User Groups
++++++++++++++++++

.. code-block:: text

/api/v1/users/user/<user_pk>/radius-group/

GET
^^^

Returns the list of RADIUS group assignments for the specified user.
Pagination is provided using page number pagination; default page size is
20 and can be overridden with the ``page_size`` parameter (maximum 100).

.. code-block:: text

GET /api/v1/users/user/<user_pk>/radius-group/

It supports filtering by organization id and organization slug.

Filters
"""""""

========================= ============================
Filter Parameter Description
========================= ============================
group__organization Filter organizations by id
group__organization__slug Filter organizations by slug
========================= ============================

POST
^^^^

Creates a RADIUS user group assignment for the specified user.

======== ==============================================
Param Description
======== ==============================================
group UUID of the RADIUS group to assign (required)
priority Priority of the assignment (optional, integer)
======== ==============================================

.. note::

The provided ``group`` must belong to the same organization as the
user; attempting to assign a group from another organization will
return a ``400`` error with ``does_not_exist`` for the ``group``
field.

.. code-block:: text

/api/v1/users/user/<user_pk>/radius-group/<uuid>/

GET (detail)
^^^^^^^^^^^^

Returns a single RADIUS user group assignment by its UUID.

PUT
^^^

Fully updates the RADIUS user group assignment.

======== ==============================================
Param Description
======== ==============================================
group UUID of the RADIUS group to assign (optional)
priority Priority of the assignment (optional, integer)
======== ==============================================

PATCH
^^^^^

Partially updates a RADIUS user group assignment.

======== ==============================================
Param Description
======== ==============================================
group UUID of the RADIUS group to assign (optional)
priority Priority of the assignment (optional, integer)
======== ==============================================

DELETE
^^^^^^

Deletes the RADIUS user group assignment identified by the UUID.
36 changes: 36 additions & 0 deletions openwisp_radius/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,42 @@ def validate(self, data):
return data


class RadiusUserGroupSerializer(FilterSerializerByOrgManaged, ValidatedModelSerializer):
class Meta:
model = RadiusUserGroup
fields = ("id", "group", "priority", "created", "modified")
read_only_fields = ("id", "created", "modified")

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
view = self.context.get("view")
if (
view
and getattr(view, "get_parent_queryset", None)
and not getattr(view, "swagger_fake_view", False)
):
self._user = view.get_parent_queryset().first()
else:
self._user = None
if self._user and view and getattr(view.request, "user", None):
orgs = view.request.user.organizations_managed
self.fields["group"].queryset = (
self.fields["group"]
.queryset.filter(organization__in=orgs)
.filter(organization__in=self._user.organizations_dict.keys())
Copy link
Member

@nemesifier nemesifier Mar 9, 2026

Choose a reason for hiding this comment

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

Here the code filters using user.organizations_dict.keys() but above user.organizations_managed is used.

Is this difference intentional and if so what's the motivation?

Keep in mind organizations_dict.keys() returns all the organizations the user is associated with, irregardless of whether the user is manager, owner or end user.

The discrepancy doesn't look intentional, if it is we need to add a comment explaining the nuances.

)
else:
self.fields["group"].queryset = self.fields["group"].queryset.none()

def validate(self, data):
if self._user:
if "username" not in data:
data["username"] = self._user.username
if "user" not in data:
data["user"] = self._user
return super().validate(data)


class GroupSerializer(serializers.ModelSerializer):
class Meta:
model = Group
Expand Down
10 changes: 10 additions & 0 deletions openwisp_radius/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,16 @@ def get_api_urls(api_views=None):
api_views.radius_group_detail,
name="radius_group_detail",
),
path(
"users/user/<str:user_pk>/radius-group/",
api_views.radius_user_group_list,
name="radius_user_group_list",
),
path(
"users/user/<str:user_pk>/radius-group/<uuid:pk>/",
api_views.radius_user_group_detail,
name="radius_user_group_detail",
),
]
else:
return []
127 changes: 126 additions & 1 deletion openwisp_radius/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,11 @@
from openwisp_radius.api.serializers import RadiusUserSerializer
from openwisp_users.api.authentication import BearerAuthentication, SesameAuthentication
from openwisp_users.api.filters import OrganizationManagedFilter
from openwisp_users.api.mixins import FilterByOrganizationManaged, ProtectedAPIMixin
from openwisp_users.api.mixins import (
FilterByOrganizationManaged,
FilterByParentManaged,
ProtectedAPIMixin,
)
from openwisp_users.api.permissions import IsOrganizationManager
from openwisp_users.api.views import ChangePasswordView as BasePasswordChangeView
from openwisp_users.backends import UsersAuthenticationBackend
Expand All @@ -69,6 +73,7 @@
RadiusAccountingSerializer,
RadiusBatchSerializer,
RadiusGroupSerializer,
RadiusUserGroupSerializer,
UserRadiusUsageSerializer,
ValidatePhoneTokenSerializer,
)
Expand Down Expand Up @@ -936,3 +941,123 @@ class RadiusGroupDetailView(


radius_group_detail = RadiusGroupDetailView.as_view()


class BaseRadiusUserGroupView(ProtectedAPIMixin, FilterByParentManaged):
"""
Base view for RadiusUserGroup management.
Provides user parent filtering and queryset logic.
"""

serializer_class = RadiusUserGroupSerializer
queryset = RadiusUserGroup.objects.select_related("group", "user").order_by(
"-created"
)

def get_queryset(self):
if getattr(self, "swagger_fake_view", False):
return self.queryset.none()
qs = (
super()
.get_queryset()
.filter(
user_id=self.kwargs["user_pk"],
)
)
if self.request.user.is_superuser:
return qs
return qs.filter(
group__organization__in=self.request.user.organizations_managed
)

def get_parent_queryset(self):
return User.objects.filter(pk=self.kwargs["user_pk"])

def get_organization_queryset(self, qs):
"""Filter users by organizations the request user manages."""
orgs = self.request.user.organizations_managed
app_label = User._meta.app_config.label
filter_kwargs = {
# exclude superusers
"is_superuser": False,
# ensure user is member of the org
f"{app_label}_organizationuser__organization_id__in": orgs,
}
return qs.filter(**filter_kwargs).distinct()


class RadiusUserGroupFilter(OrganizationManagedFilter, filters.FilterSet):
"""
Filter RADIUS groups by organizations managed by the user.
"""

# Disable parent's organization_slug; use group__organization__slug instead
organization_slug = None

class Meta(OrganizationManagedFilter.Meta):
model = RadiusUserGroup
fields = ["group__organization", "group__organization__slug"]


@method_decorator(
name="get",
decorator=swagger_auto_schema(
operation_description="""
Returns the list of RADIUS user groups for a specific user.
""",
),
)
@method_decorator(
name="post",
decorator=swagger_auto_schema(
operation_description="""
Creates a new RADIUS user group assignment for the user.
""",
),
)
class RadiusUserGroupListCreateView(BaseRadiusUserGroupView, ListCreateAPIView):
pagination_class = RadiusGroupPaginator
filter_backends = [DjangoFilterBackend]
filterset_class = RadiusUserGroupFilter


radius_user_group_list = RadiusUserGroupListCreateView.as_view()


@method_decorator(
name="get",
decorator=swagger_auto_schema(
operation_description="""
Returns a single RADIUS user group by its UUID.
""",
),
)
@method_decorator(
name="put",
decorator=swagger_auto_schema(
operation_description="""
Updates a RADIUS user group identified by its UUID.
""",
),
)
@method_decorator(
name="patch",
decorator=swagger_auto_schema(
operation_description="""
Partially updates a RADIUS user group identified by its UUID.
""",
),
)
@method_decorator(
name="delete",
decorator=swagger_auto_schema(
operation_description="""
Deletes a RADIUS user group identified by its UUID.
""",
),
)
class RadiusUserGroupDetailView(BaseRadiusUserGroupView, RetrieveUpdateDestroyAPIView):
organization_field = "group__organization"


radius_user_group_detail = RadiusUserGroupDetailView.as_view()
Loading
Loading