diff --git a/openwisp_radius/api/urls.py b/openwisp_radius/api/urls.py index 83837448..85d35f17 100644 --- a/openwisp_radius/api/urls.py +++ b/openwisp_radius/api/urls.py @@ -8,7 +8,7 @@ def get_api_urls(api_views=None): if not api_views: api_views = views if app_settings.RADIUS_API: - return [ + api_urls = [ path("freeradius/authorize/", api_views.authorize, name="authorize"), path("freeradius/postauth/", api_views.postauth, name="postauth"), path("freeradius/accounting/", api_views.accounting, name="accounting"), @@ -89,5 +89,14 @@ def get_api_urls(api_views=None): name="radius_accounting_list", ), ] + if api_views.monitoring_accounting is not None: + api_urls.append( + path( + "radius/monitoring/sessions/", + api_views.monitoring_accounting, + name="monitoring_accounting_list", + ), + ) + return api_urls else: return [] diff --git a/openwisp_radius/api/views.py b/openwisp_radius/api/views.py index d478b180..24830211 100644 --- a/openwisp_radius/api/views.py +++ b/openwisp_radius/api/views.py @@ -847,3 +847,10 @@ class RadiusAccountingView(ProtectedAPIMixin, FilterByOrganizationManaged, ListA radius_accounting = RadiusAccountingView.as_view() + +try: + from ..integrations.monitoring.views import MonitoringAccountingView + + monitoring_accounting = MonitoringAccountingView.as_view() +except ImportError: + monitoring_accounting = None diff --git a/openwisp_radius/integrations/monitoring/admin.py b/openwisp_radius/integrations/monitoring/admin.py index 4e00d265..5376e4e9 100644 --- a/openwisp_radius/integrations/monitoring/admin.py +++ b/openwisp_radius/integrations/monitoring/admin.py @@ -25,7 +25,7 @@ def get_extra_context(self, pk=None): ctx.update( { "radius_accounting_api_endpoint": reverse( - "radius:radius_accounting_list" + "radius:monitoring_accounting_list" ), "radius_accounting": reverse( f"admin:{RadiusAccounting._meta.app_label}" diff --git a/openwisp_radius/integrations/monitoring/serializers.py b/openwisp_radius/integrations/monitoring/serializers.py new file mode 100644 index 00000000..20107bd7 --- /dev/null +++ b/openwisp_radius/integrations/monitoring/serializers.py @@ -0,0 +1,50 @@ +from django.utils import formats, timezone +from rest_framework import serializers +from swapper import load_model + +RadiusAccounting = load_model("openwisp_radius", "RadiusAccounting") + + +class MonitoringRadiusAccountingSerializer(serializers.ModelSerializer): + """ + Read-only serializer for RADIUS accounting in monitoring integration + that formats datetime fields server-side using Django's localization + for consistency with Django admin datetime formatting. + """ + + start_time = serializers.SerializerMethodField() + stop_time = serializers.SerializerMethodField() + + def _format_datetime(self, dt): + """ + Format a datetime using Django's localization settings. + Handles both naive and timezone-aware datetimes. + """ + if dt is None: + return None + if timezone.is_aware(dt): + dt = timezone.localtime(dt) + return formats.date_format(dt, "DATETIME_FORMAT") + + def get_start_time(self, obj): + """Format start_time using Django's localization settings""" + return self._format_datetime(obj.start_time) + + def get_stop_time(self, obj): + """Format stop_time using Django's localization settings""" + return self._format_datetime(obj.stop_time) + + class Meta: + model = RadiusAccounting + fields = [ + "session_id", + "unique_id", + "username", + "input_octets", + "output_octets", + "calling_station_id", + "called_station_id", + "start_time", + "stop_time", + ] + read_only_fields = fields diff --git a/openwisp_radius/integrations/monitoring/static/radius-monitoring/js/device-change.js b/openwisp_radius/integrations/monitoring/static/radius-monitoring/js/device-change.js index 24f788e0..f0d12fac 100644 --- a/openwisp_radius/integrations/monitoring/static/radius-monitoring/js/device-change.js +++ b/openwisp_radius/integrations/monitoring/static/radius-monitoring/js/device-change.js @@ -14,14 +14,6 @@ const deviceMac = encodeURIComponent($("#id_mac_address").val()), apiEndpoint = `${radiusAccountingApiEndpoint}?called_station_id=${deviceMac}`; - function getFormattedDateTimeString(dateTimeString) { - // Strip the timezone from the dateTimeString. - // This is done to show the time in server's timezone - // because RadiusAccounting admin also shows the time in server's timezone. - let strippedDateTime = new Date(dateTimeString.replace(/[-+]\d{2}:\d{2}$/, "")); - return strippedDateTime.toLocaleString(); - } - function fetchRadiusSessions() { if ($("#radius-session-tbody").children().length) { // Don't fetch if RADIUS sessions are already present @@ -58,11 +50,8 @@ ); response.forEach((element, index) => { - element.start_time = getFormattedDateTimeString(element.start_time); if (!element.stop_time) { element.stop_time = `${onlineMsg}`; - } else { - element.stop_time = getFormattedDateTimeString(element.stop_time); } $("#radius-session-tbody").append( ` diff --git a/openwisp_radius/integrations/monitoring/templates/admin/config/radius-monitoring/device/change_form.html b/openwisp_radius/integrations/monitoring/templates/admin/config/radius-monitoring/device/change_form.html index cbe54425..6b1a55ce 100644 --- a/openwisp_radius/integrations/monitoring/templates/admin/config/radius-monitoring/device/change_form.html +++ b/openwisp_radius/integrations/monitoring/templates/admin/config/radius-monitoring/device/change_form.html @@ -36,7 +36,6 @@

{% trans "RADIUS Sessions" %}

{% endif %} {% endblock %} diff --git a/openwisp_radius/integrations/monitoring/views.py b/openwisp_radius/integrations/monitoring/views.py new file mode 100644 index 00000000..a0685054 --- /dev/null +++ b/openwisp_radius/integrations/monitoring/views.py @@ -0,0 +1,29 @@ +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.generics import ListAPIView +from swapper import load_model + +from openwisp_radius.api.freeradius_views import ( + AccountingFilter, + AccountingViewPagination, +) +from openwisp_users.api.mixins import FilterByOrganizationManaged, ProtectedAPIMixin + +from .serializers import MonitoringRadiusAccountingSerializer + +RadiusAccounting = load_model("openwisp_radius", "RadiusAccounting") + + +class MonitoringAccountingView( + ProtectedAPIMixin, FilterByOrganizationManaged, ListAPIView +): + """ + API view for RADIUS accounting in monitoring integration. + Uses server-side datetime formatting for consistency with Django admin. + """ + + throttle_scope = "radius_accounting_list" + serializer_class = MonitoringRadiusAccountingSerializer + pagination_class = AccountingViewPagination + filter_backends = (DjangoFilterBackend,) + filterset_class = AccountingFilter + queryset = RadiusAccounting.objects.all().order_by("-start_time") diff --git a/tests/openwisp2/sample_radius/api/views.py b/tests/openwisp2/sample_radius/api/views.py index b8efb235..ec546b09 100644 --- a/tests/openwisp2/sample_radius/api/views.py +++ b/tests/openwisp2/sample_radius/api/views.py @@ -24,6 +24,9 @@ from openwisp_radius.api.views import ( ValidatePhoneTokenView as BaseValidatePhoneTokenView, ) +from openwisp_radius.integrations.monitoring.views import ( + MonitoringAccountingView as BaseMonitoringAccountingView, +) class AuthorizeView(BaseAuthorizeView): @@ -98,6 +101,10 @@ class RadiusAccountingView(BaseRadiusAccountingView): pass +class MonitoringAccountingView(BaseMonitoringAccountingView): + pass + + authorize = AuthorizeView.as_view() postauth = PostAuthView.as_view() accounting = AccountingView.as_view() @@ -116,3 +123,4 @@ class RadiusAccountingView(BaseRadiusAccountingView): change_phone_number = ChangePhoneNumberView.as_view() download_rad_batch_pdf = DownloadRadiusBatchPdfView.as_view() radius_accounting = RadiusAccountingView.as_view() +monitoring_accounting = MonitoringAccountingView.as_view()