diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b94ee487..3098eea4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,12 +43,23 @@ jobs: id: deps run: | sudo apt update -qq - sudo apt-get -qq -y install xmlsec1 gettext + sudo apt-get -qq -y install xmlsec1 gettext \ + sqlite3 \ + fping \ + gdal-bin \ + libproj-dev \ + libgeos-dev \ + libspatialite-dev \ + spatialite-bin \ + libsqlite3-mod-spatialite + sudo npm install -g jslint stylelint jshint pip install -U pip wheel setuptools pip install -U -r requirements-test.txt pip install -e .[saml,openvpn_status] pip install ${{ matrix.django-version }} - sudo npm install -g jslint + + - name: Start InfluxDB and Redis container + run: docker compose up -d influxdb redis - name: QA checks run: | @@ -57,10 +68,11 @@ jobs: - name: Tests if: ${{ !cancelled() && steps.deps.conclusion == 'success' }} run: | - coverage run runtests.py --parallel || ./runtests.py + coverage run --parallel-mode runtests.py --parallel || ./runtests.py # SAMPLE tests do not influence coverage, so we can speed up tests with --parallel - SAMPLE_APP=1 coverage run ./runtests.py --parallel > /dev/null 2>&1 || SAMPLE_APP=1 ./runtests.py + SAMPLE_APP=1 coverage run --parallel-mode ./runtests.py --parallel > /dev/null 2>&1 || SAMPLE_APP=1 ./runtests.py coverage combine + MONITORING_INTEGRATION=1 coverage run --append runtests.py coverage xml - name: Upload Coverage diff --git a/.jshintrc b/.jshintrc new file mode 100644 index 00000000..99b4b79c --- /dev/null +++ b/.jshintrc @@ -0,0 +1,7 @@ +{ + "unused": true, + "esversion": 6, + "curly": true, + "strict": "global", + "browser": true +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..a7592770 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +version: "3" + +services: + influxdb: + image: influxdb:1.8-alpine + volumes: + - influxdb-data:/var/lib/influxdb + ports: + - "8086:8086" + environment: + INFLUXDB_DB: openwisp2 + INFLUXDB_USER: openwisp + INFLUXDB_USER_PASSWORD: openwisp + + redis: + image: redis:alpine + ports: + - "6379:6379" + entrypoint: redis-server --appendonly yes + +volumes: + influxdb-data: {} diff --git a/docs/images/radius-dashboard-charts.png b/docs/images/radius-dashboard-charts.png new file mode 100644 index 00000000..3e707313 Binary files /dev/null and b/docs/images/radius-dashboard-charts.png differ diff --git a/docs/images/radius-traffic-chart.png b/docs/images/radius-traffic-chart.png new file mode 100644 index 00000000..854fc70d Binary files /dev/null and b/docs/images/radius-traffic-chart.png differ diff --git a/docs/images/total-user-registration-chart.png b/docs/images/total-user-registration-chart.png new file mode 100644 index 00000000..43e4528d Binary files /dev/null and b/docs/images/total-user-registration-chart.png differ diff --git a/docs/images/unique-radius-session-chart.png b/docs/images/unique-radius-session-chart.png new file mode 100644 index 00000000..e7e59773 Binary files /dev/null and b/docs/images/unique-radius-session-chart.png differ diff --git a/docs/images/user-registration-chart.png b/docs/images/user-registration-chart.png new file mode 100644 index 00000000..941e4bb0 Binary files /dev/null and b/docs/images/user-registration-chart.png differ diff --git a/docs/index.rst b/docs/index.rst index 73d02ecc..e0f04bbc 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -40,6 +40,7 @@ OpenWISP architecture. user/saml.rst user/enforcing_limits.rst user/change_of_authorization.rst + user/radius_monitoring user/management_commands.rst user/rest-api.rst user/settings.rst diff --git a/docs/user/radius_monitoring.rst b/docs/user/radius_monitoring.rst new file mode 100644 index 00000000..7f80f0af --- /dev/null +++ b/docs/user/radius_monitoring.rst @@ -0,0 +1,80 @@ +.. _integration_with_openwisp_monitoring: + +Integration with OpenWISP Monitoring +==================================== + +OpenWISP RADIUS includes an optional Django sub-app that adds integration +with :doc:`OpenWISP Monitoring ` to provide RADIUS +metrics. + +.. image:: ../images/radius-dashboard-charts.png + :alt: RADIUS session dashboard charts + +RADIUS metrics +-------------- + +1. User registrations +~~~~~~~~~~~~~~~~~~~~~ + +.. image:: ../images/user-registration-chart.png + :alt: User registration chart + +This chart shows number of users signed up using different registration +methods. + +2. Total user registrations +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. image:: ../images/total-user-registration-chart.png + :alt: Total user registration chart + +This chart shows total users registered using different registration +methods in the system on a given date. + +3. Unique RADIUS Sessions +~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. image:: ../images/unique-radius-session-chart.png + :alt: Unique RADIUS session chart + +This chart shows unique RADIUS sessions. It is helpful to know how many +unique users has used the system in a given time. + +4. RADIUS traffic +~~~~~~~~~~~~~~~~~ + +.. image:: ../images/radius-traffic-chart.png + :alt: RADIUS traffic chart + +This chart shows the RADIUS traffic generated by user sessions. + +Enabling RADIUS metrics in Django project +----------------------------------------- + +.. include:: /partials/settings-note.rst + +You can enable the monitoring integration by including +``openwisp_radius.integrations.monitoring`` in ``INSTALLED_APPS`` of your +Django project's settings as following: + +.. code-block:: python + + # In your_project/settings.py + + INSTALLED_APPS.append("openwisp_radius.integrations.monitoring") + +.. note:: + + Ensure your Django project is correctly configured to utilize OpenWISP + Monitoring. For production environments, it is advisable to deploy + OpenWISP using :doc:`Ansible OpenWISP ` or + :doc:`Docker OpenWISP `, as they simplify the + deployment process considerably. + +.. important:: + + If you are registering a :ref:`"registration method" + ` in any other Django + application, then ``openwisp_radius.integrations.monitoring`` should + come after that app in the ``INSTALLED_APPS``. Otherwise, the + registration method would not appear in the chart. diff --git a/docs/user/settings.rst b/docs/user/settings.rst index 82f7db0e..0b58a441 100644 --- a/docs/user/settings.rst +++ b/docs/user/settings.rst @@ -667,8 +667,8 @@ If this is enabled, each registered user should be verified using a verification method. The following choices are available by default: - ``''`` (empty string): unspecified -- ``manual``: manually created -- ``email``: Email (No Identity Verification) +- ``manual``: Manually created +- ``email``: Email - ``mobile_phone``: Mobile phone number :ref:`verification via SMS ` - ``social_login``: :doc:`social login feature ` @@ -690,6 +690,8 @@ verification method. The following choices are available by default: **Disclaimer:** these are just suggestions on possible configurations of OpenWISP RADIUS and must not be considered as legal advice. +.. _openwisp_radius_register_registration_method: + Adding support for more registration/verification methods +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ diff --git a/openwisp_radius/api/urls.py b/openwisp_radius/api/urls.py index fed1d5ad..193224f0 100644 --- a/openwisp_radius/api/urls.py +++ b/openwisp_radius/api/urls.py @@ -83,6 +83,11 @@ def get_api_urls(api_views=None): api_views.download_rad_batch_pdf, name='download_rad_batch_pdf', ), + path( + 'radius/sessions/', + api_views.radius_accounting, + name='radius_accounting_list', + ), ] else: return [] diff --git a/openwisp_radius/api/views.py b/openwisp_radius/api/views.py index f59dac11..6bb21916 100644 --- a/openwisp_radius/api/views.py +++ b/openwisp_radius/api/views.py @@ -11,6 +11,7 @@ from django.contrib.sites.shortcuts import get_current_site from django.core.cache import cache from django.core.exceptions import ValidationError +from django.db.models import Q from django.db.utils import IntegrityError from django.http import Http404, HttpResponse from django.utils import timezone @@ -18,7 +19,7 @@ from django.utils.translation import gettext_lazy as _ from django.utils.translation.trans_real import get_language_from_request from django.views.decorators.csrf import csrf_exempt -from django_filters.rest_framework import DjangoFilterBackend +from django_filters.rest_framework import CharFilter, DjangoFilterBackend from drf_yasg.utils import no_body, swagger_auto_schema from rest_framework import serializers, status from rest_framework.authentication import SessionAuthentication @@ -41,6 +42,7 @@ from openwisp_radius.api.serializers import RadiusUserSerializer from openwisp_users.api.authentication import BearerAuthentication, SesameAuthentication +from openwisp_users.api.mixins import FilterByOrganizationManaged, ProtectedAPIMixin from openwisp_users.api.permissions import IsOrganizationManager from openwisp_users.api.views import ChangePasswordView as BasePasswordChangeView from openwisp_users.backends import UsersAuthenticationBackend @@ -801,3 +803,46 @@ def create_phone_token(self, *args, **kwargs): change_phone_number = ChangePhoneNumberView.as_view() + + +class RadiusAccountingFilter(AccountingFilter): + called_station_id = CharFilter( + field_name='called_station_id', method='filter_mac_address' + ) + calling_station_id = CharFilter( + field_name='calling_station_id', method='filter_mac_address' + ) + + def filter_mac_address(self, queryset, name, value): + """ + The input MAC address in any of these two formats: + - AA-BB-CC-DD-EE-FF (quadrants separated by hyphen) + - AA:BB:CC:DD:EE:FF (quadrants separated by colon) + The below lookup ensures that the filtering is + case-insensitive and works across different formats. + """ + lookup = f'{name}__iexact' + return queryset.filter( + Q(**{lookup: value.replace(':', '-')}) + | Q(**{lookup: value.replace('-', ':')}) + ) + + +@method_decorator( + name='get', + decorator=swagger_auto_schema( + operation_description=""" + Returns all RADIUS sessions of user managed organizations. + """, + ), +) +class RadiusAccountingView(ProtectedAPIMixin, FilterByOrganizationManaged, ListAPIView): + throttle_scrope = 'radius_accounting_list' + serializer_class = RadiusAccountingSerializer + pagination_class = AccountingViewPagination + filter_backends = (DjangoFilterBackend,) + filterset_class = RadiusAccountingFilter + queryset = RadiusAccounting.objects.all().order_by('-start_time') + + +radius_accounting = RadiusAccountingView.as_view() diff --git a/openwisp_radius/integrations/__init__.py b/openwisp_radius/integrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openwisp_radius/integrations/monitoring/__init__.py b/openwisp_radius/integrations/monitoring/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openwisp_radius/integrations/monitoring/admin.py b/openwisp_radius/integrations/monitoring/admin.py new file mode 100644 index 00000000..e859e26c --- /dev/null +++ b/openwisp_radius/integrations/monitoring/admin.py @@ -0,0 +1,40 @@ +from django.contrib import admin +from django.urls import reverse +from swapper import load_model + +Device = load_model('config', 'Device') +RadiusAccounting = load_model('openwisp_radius', 'RadiusAccounting') + +BaseDeviceAdmin = admin.site._registry[Device].__class__ + + +class DeviceAdmin(BaseDeviceAdmin): + change_form_template = 'admin/config/radius-monitoring/device/change_form.html' + + class Media: + js = tuple(BaseDeviceAdmin.Media.js) + ( + 'radius-monitoring/js/device-change.js', + ) + css = { + 'all': ('radius-monitoring/css/device-change.css',) + + BaseDeviceAdmin.Media.css['all'] + } + + def get_extra_context(self, pk=None): + ctx = super().get_extra_context(pk) + ctx.update( + { + 'radius_accounting_api_endpoint': reverse( + 'radius:radius_accounting_list' + ), + 'radius_accounting': reverse( + f'admin:{RadiusAccounting._meta.app_label}' + f'_{RadiusAccounting._meta.model_name}_changelist' + ), + } + ) + return ctx + + +admin.site.unregister(Device) +admin.site.register(Device, DeviceAdmin) diff --git a/openwisp_radius/integrations/monitoring/apps.py b/openwisp_radius/integrations/monitoring/apps.py new file mode 100644 index 00000000..c1ad0be1 --- /dev/null +++ b/openwisp_radius/integrations/monitoring/apps.py @@ -0,0 +1,121 @@ +from django.apps import AppConfig +from django.db import models +from django.db.models import Count, Sum +from django.db.models.functions import Cast, Round +from django.db.models.signals import post_save +from django.utils.timezone import localdate +from django.utils.translation import gettext_lazy as _ +from openwisp_monitoring.monitoring.configuration import ( + _register_chart_configuration_choice, + register_metric, +) +from swapper import load_model + +from openwisp_utils.admin_theme import register_dashboard_chart + +from .utils import get_datetime_filter_start_date, get_datetime_filter_stop_date + + +class OpenwispRadiusMonitoringConfig(AppConfig): + name = 'openwisp_radius.integrations.monitoring' + label = 'openwisp_radius_monitoring' + verbose_name = _('OpenWISP RADIUS Monitoring') + + def ready(self): + super().ready() + self.register_dashboard_charts() + self.register_radius_metrics() + self.connect_signal_receivers() + + def register_dashboard_charts(self): + register_dashboard_chart( + position=30, + config={ + 'name': _("Today's RADIUS sessions"), + 'query_params': { + 'app_label': 'openwisp_radius', + 'model': 'radiusaccounting', + 'filter': { + 'start_time__date': localdate, + }, + 'aggregate': { + 'open': Count( + 'session_id', filter=models.Q(stop_time__isnull=True) + ), + 'closed': Count( + 'session_id', filter=models.Q(stop_time__isnull=False) + ), + }, + }, + 'colors': { + 'open': '#267126', + 'closed': '#a72d1d', + }, + 'filters': { + 'key': 'stop_time__isnull', + 'open': 'True', + 'closed': 'False', + }, + 'main_filters': { + 'start_time__gte': get_datetime_filter_start_date, + 'start_time__lt': get_datetime_filter_stop_date, + }, + 'labels': { + 'open': 'Open', + 'closed': 'Closed', + }, + }, + ) + register_dashboard_chart( + position=31, + config={ + 'name': _("Today's RADIUS traffic (GB)"), + 'query_params': { + 'app_label': 'openwisp_radius', + 'model': 'radiusaccounting', + 'filter': { + 'start_time__date': localdate, + }, + 'aggregate': { + 'download_traffic': Round( + Cast(Sum('input_octets'), models.FloatField()) / 10**9 + ), + 'upload_traffic': Round( + Cast(Sum('output_octets'), models.FloatField()) / 10**9 + ), + }, + }, + 'colors': { + 'download_traffic': '#1f77b4', + 'upload_traffic': '#ff7f0e', + }, + 'labels': { + 'download_traffic': 'Download traffic (GB)', + 'upload_traffic': 'Upload traffic (GB)', + }, + 'main_filters': { + 'start_time__gte': get_datetime_filter_start_date, + 'start_time__lt': get_datetime_filter_stop_date, + }, + 'filtering': 'False', + }, + ) + + def register_radius_metrics(self): + from .configuration import RADIUS_METRICS + + for metric_key, metric_config in RADIUS_METRICS.items(): + register_metric(metric_key, metric_config) + for chart_key, chart_config in metric_config.get('charts', {}).items(): + _register_chart_configuration_choice(chart_key, chart_config) + + def connect_signal_receivers(self): + from .receivers import post_save_radiusaccounting + + RadiusAccounting = load_model('openwisp_radius', 'RadiusAccounting') + + post_save.connect( + post_save_radiusaccounting, + sender=RadiusAccounting, + dispatch_uid='post_save_radiusaccounting_radius_acc_metric', + ) diff --git a/openwisp_radius/integrations/monitoring/configuration.py b/openwisp_radius/integrations/monitoring/configuration.py new file mode 100644 index 00000000..35c1df6c --- /dev/null +++ b/openwisp_radius/integrations/monitoring/configuration.py @@ -0,0 +1,246 @@ +from copy import deepcopy + +from django.utils.translation import gettext_lazy as _ +from openwisp_monitoring.monitoring.configuration import DEFAULT_COLORS + +from openwisp_radius.registration import REGISTRATION_METHOD_CHOICES + +user_signups_chart_traces = {'total': 'lines'} +user_signups_chart_order = ['total'] +user_signups_chart_trace_labels = { + 'total': _('Total'), +} +user_signups_chart_summary_labels = [_('Total new users')] + +for method, label in REGISTRATION_METHOD_CHOICES: + if method == '': + method = 'unspecified' + user_signups_chart_traces[method] = 'stackedbar' + user_signups_chart_summary_labels.append( + _('New %(label)s users' % {"label": label}) + ) + user_signups_chart_trace_labels[method] = label + user_signups_chart_order.append(method) + + +user_singups_chart_config = { + 'type': 'stackedbar+lines', + 'trace_type': user_signups_chart_traces, + 'trace_order': user_signups_chart_order, + 'title': _('User Registrations'), + 'label': _('User Registrations'), + 'description': _('Daily user registrations grouped by registration method'), + 'summary_labels': user_signups_chart_summary_labels, + 'trace_labels': user_signups_chart_trace_labels, + 'order': 240, + '__all__': True, + 'unit': '', + 'calculate_total': True, + 'query': { + 'influxdb': ( + "SELECT SUM(count) FROM " + " {key} WHERE time >= '{time}' {end_date} {organization_id}" + " GROUP BY time(1d), method" + ) + }, + 'query_default_param': { + 'organization_id': '', + }, + 'colors': [ + DEFAULT_COLORS[7], + '#8C564B', + '#17BECF', + '#9467BD', + '#D62728', + '#E377C2', + '#1F77B4', + '#2CA02C', + '#BCBD22', + ], +} + +total_user_singups_chart_config = deepcopy(user_singups_chart_config) +total_user_singups_chart_config['query']['influxdb'] = ( + "SELECT LAST(count) FROM " + " {key} WHERE time >= '{time}' {end_date} {organization_id}" + " GROUP BY time(1d), method FILL(linear)" +) +total_user_singups_chart_config['title'] = _('Total Registered Users') +total_user_singups_chart_config['label'] = _('Total Registered Users') +total_user_singups_chart_config['filter__all__'] = True +total_user_singups_chart_config['order'] = 241 + + +RADIUS_METRICS = { + 'user_signups': { + 'label': _('User Registrations'), + 'name': 'User Registrations', + 'key': 'user_signups', + 'field_name': 'count', + 'charts': { + 'user_signups': user_singups_chart_config, + }, + }, + 'tot_user_signups': { + 'label': _('Total User Registrations'), + 'name': 'Total User Registrations', + 'key': 'tot_user_signups', + 'field_name': 'count', + 'charts': { + 'tot_user_signups': total_user_singups_chart_config, + }, + }, + 'radius_acc': { + 'label': _('RADIUS Accounting'), + 'name': '{name}', + 'key': 'radius_acc', + 'field_name': 'input_octets', + 'related_fields': ['output_octets', 'username'], + 'charts': { + 'radius_traffic': { + 'type': 'stackedbar+lines', + 'calculate_total': True, + 'trace_type': { + 'download': 'stackedbar', + 'upload': 'stackedbar', + 'total': 'lines', + }, + 'trace_order': ['total', 'download', 'upload'], + 'title': _('RADIUS Sessions Traffic'), + 'label': _('RADIUS Traffic'), + 'description': _( + 'RADIUS Network traffic (total, download and upload).' + ), + 'summary_labels': [ + _('Total traffic'), + _('Total download traffic'), + _('Total upload traffic'), + ], + 'unit': 'adaptive_prefix+B', + 'order': 221, + 'query': { + 'influxdb': ( + "SELECT SUM(output_octets) / 1000000000 AS upload, " + "SUM(input_octets) / 1000000000 AS download FROM {key} " + "WHERE time >= '{time}' {end_date} " + "AND content_type = '{content_type}' " + "AND object_id = '{object_id}' " + "GROUP BY time(1d)" + ) + }, + 'colors': [ + DEFAULT_COLORS[7], + DEFAULT_COLORS[0], + DEFAULT_COLORS[1], + ], + }, + 'rad_session': { + 'type': 'stackedbar+lines', + 'calculate_total': True, + 'fill': 'none', + 'trace_type': user_signups_chart_traces, + 'trace_order': user_signups_chart_order, + 'title': _('Unique RADIUS Sessions'), + 'label': _('Unique RADIUS Sessions'), + 'description': _( + 'RADIUS Network traffic (total, download and upload).' + ), + 'summary_labels': user_signups_chart_summary_labels, + 'trace_labels': user_signups_chart_trace_labels, + 'unit': '', + 'order': 222, + 'query': { + 'influxdb': ( + "SELECT COUNT(DISTINCT(username)) FROM {key} " + "WHERE time >= '{time}' {end_date} " + "AND content_type = '{content_type}' " + "AND object_id = '{object_id}' " + "GROUP by time(1d), method" + ) + }, + 'query_default_param': { + 'organization_id': '', + 'location_id': '', + }, + 'colors': user_singups_chart_config['colors'], + }, + }, + }, + 'gen_radius_acc': { + 'label': _('General RADIUS Accounting'), + 'name': 'General RADIUS Accounting', + 'key': 'radius_acc', + 'field_name': 'input_octets', + 'related_fields': ['output_octets'], + 'charts': { + 'gen_rad_traffic': { + 'type': 'stackedbar+lines', + 'calculate_total': True, + 'fill': 'none', + 'trace_type': { + 'download': 'stackedbar', + 'upload': 'stackedbar', + 'total': 'lines', + }, + 'trace_order': ['total', 'download', 'upload'], + 'title': _('Traffic of RADIUS Sessions'), + 'label': _('General RADIUS Traffic'), + 'description': _( + 'RADIUS Network traffic (total, download and upload).' + ), + 'summary_labels': [ + _('Total traffic'), + _('Total download traffic'), + _('Total upload traffic'), + ], + 'unit': 'adaptive_prefix+B', + 'order': 242, + 'query': { + 'influxdb': ( + "SELECT SUM(output_octets) / 1000000000 AS upload, " + "SUM(input_octets) / 1000000000 AS download FROM {key} " + "WHERE time >= '{time}' {end_date} {organization_id} " + "{location_id} GROUP BY time(1d)" + ) + }, + 'query_default_param': { + 'organization_id': '', + 'location_id': '', + }, + 'colors': [ + DEFAULT_COLORS[7], + DEFAULT_COLORS[0], + DEFAULT_COLORS[1], + ], + }, + 'gen_rad_session': { + 'type': 'stackedbar+lines', + 'calculate_total': True, + 'fill': 'none', + 'trace_type': user_signups_chart_traces, + 'trace_order': user_signups_chart_order, + 'title': _('Unique RADIUS Sessions'), + 'label': _('General RADIUS Sessions'), + 'description': _( + 'RADIUS Network traffic (total, download and upload).' + ), + 'summary_labels': user_signups_chart_summary_labels, + 'trace_labels': user_signups_chart_trace_labels, + 'unit': '', + 'order': 243, + 'query': { + 'influxdb': ( + "SELECT COUNT(DISTINCT(username)) FROM {key} " + "WHERE time >= '{time}' {end_date} {organization_id} " + "{location_id} GROUP by time(1d), method" + ) + }, + 'query_default_param': { + 'organization_id': '', + 'location_id': '', + }, + 'colors': user_singups_chart_config['colors'], + }, + }, + }, +} diff --git a/openwisp_radius/integrations/monitoring/migrations/0001_initial.py b/openwisp_radius/integrations/monitoring/migrations/0001_initial.py new file mode 100644 index 00000000..f51954c0 --- /dev/null +++ b/openwisp_radius/integrations/monitoring/migrations/0001_initial.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.3 on 2023-08-09 13:56 + +import swapper +from django.db import migrations + +from . import create_general_metrics, delete_general_metrics + + +class Migration(migrations.Migration): + initial = True + dependencies = [ + swapper.dependency('monitoring', 'Metric'), + swapper.dependency('monitoring', 'Chart'), + ] + + operations = [ + migrations.RunPython( + create_general_metrics, reverse_code=delete_general_metrics + ) + ] diff --git a/openwisp_radius/integrations/monitoring/migrations/__init__.py b/openwisp_radius/integrations/monitoring/migrations/__init__.py new file mode 100644 index 00000000..8f65bd68 --- /dev/null +++ b/openwisp_radius/integrations/monitoring/migrations/__init__.py @@ -0,0 +1,55 @@ +import swapper + +from ..configuration import RADIUS_METRICS + + +def create_general_metrics(apps, schema_editor): + Chart = swapper.load_model('monitoring', 'Chart') + Metric = swapper.load_model('monitoring', 'Metric') + + metric, created = Metric._get_or_create( + configuration='user_signups', + name='User SignUps', + key='user_signups', + object_id=None, + content_type=None, + ) + if created: + for configuration in metric.config_dict['charts'].keys(): + chart = Chart(metric=metric, configuration=configuration) + chart.full_clean() + chart.save() + + metric, created = Metric._get_or_create( + configuration='tot_user_signups', + name='Total User SignUps', + key='tot_user_signups', + object_id=None, + content_type=None, + ) + if created: + for configuration in metric.config_dict['charts'].keys(): + chart = Chart(metric=metric, configuration=configuration) + chart.full_clean() + chart.save() + + metric, created = Metric._get_or_create( + configuration='gen_radius_acc', + name='RADIUS Accounting', + key='radius_acc', + object_id=None, + content_type=None, + ) + + if created: + for configuration in metric.config_dict['charts'].keys(): + chart = Chart(metric=metric, configuration=configuration) + chart.full_clean() + chart.save() + + +def delete_general_metrics(apps, schema_editor): + Metric = apps.get_model('monitoring', 'Metric') + Metric.objects.filter( + content_type__isnull=True, object_id__isnull=True, key__in=RADIUS_METRICS.keys() + ).delete() diff --git a/openwisp_radius/integrations/monitoring/receivers.py b/openwisp_radius/integrations/monitoring/receivers.py new file mode 100644 index 00000000..674b659e --- /dev/null +++ b/openwisp_radius/integrations/monitoring/receivers.py @@ -0,0 +1,18 @@ +from django.db import transaction + +from . import tasks + + +def post_save_radiusaccounting(instance, *args, **kwargs): + if instance.stop_time is None: + return + transaction.on_commit( + lambda: tasks.post_save_radiusaccounting.delay( + username=instance.username, + organization_id=str(instance.organization_id), + input_octets=instance.input_octets, + output_octets=instance.output_octets, + calling_station_id=instance.calling_station_id, + called_station_id=instance.called_station_id, + ) + ) diff --git a/openwisp_radius/integrations/monitoring/settings.py b/openwisp_radius/integrations/monitoring/settings.py new file mode 100644 index 00000000..82f71181 --- /dev/null +++ b/openwisp_radius/integrations/monitoring/settings.py @@ -0,0 +1,11 @@ +from django.conf import settings + + +def get_settings_value(option, default): + return getattr(settings, f'OPENWISP_RADIUS_MONITORING_{option}', default) + + +SHARED_ACCOUNTING = get_settings_value( + 'SHARED_ACCOUNTING', + False, +) diff --git a/openwisp_radius/integrations/monitoring/static/radius-monitoring/css/device-change.css b/openwisp_radius/integrations/monitoring/static/radius-monitoring/css/device-change.css new file mode 100644 index 00000000..806a59f6 --- /dev/null +++ b/openwisp_radius/integrations/monitoring/static/radius-monitoring/css/device-change.css @@ -0,0 +1,17 @@ +#no-session-msg { + text-align: center; + margin-bottom: 20px; +} +#radius-sessions .tabular.inline-related { + overflow-x: auto; +} +#radius-sessions .loader { + width: 4em; + height: 4em; +} +#radius-session-tbody strong { + color: green; +} +#radius-session-tbody tr td p { + white-space: nowrap; +} 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 new file mode 100644 index 00000000..47aac605 --- /dev/null +++ b/openwisp_radius/integrations/monitoring/static/radius-monitoring/js/device-change.js @@ -0,0 +1,95 @@ +(function ($) { + 'use strict'; + + const onlineMsg = gettext('online'); + + $(document).ready(function () { + if (!$('#radius-sessions').length) { + // RADIUS sessions tab should not appear on Device add page. + return; + } + // Move the "RADIUS Sessions" tab after the "Credentials" tab. + $('ul.tabs li.credentials').after($('ul.tabs li.radius-sessions')); + + 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 + // in the table + return; + } + $.ajax({ + type: 'GET', + url: apiEndpoint, + xhrFields: { + withCredentials: true + }, + crossDomain: true, + beforeSend: function() { + $('#radius-sessions .loader').show(); + }, + complete: function () { + $('#radius-sessions .loader').hide(); + }, + success: function (response) { + if (response.length === 0) { + $('#no-session-msg').show(); + return; + } + // The called_station_id in the response is in the format accepted by + // RadiusAccountingAdmin. This ensures that we use the same format for + // filtering the RadiusAccountingAdmin table, avoiding any problem with + // different formats of MAC address in the backend. + let called_station_id = response[0].called_station_id, + radiusAccountingAdminUrl = `${radiusAccountingAdminPath}?called_station_id=${encodeURIComponent(called_station_id)}`; + $('#view-all-radius-session-wrapper a').attr('href', radiusAccountingAdminUrl); + + 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( + ` + +

${element.session_id}

+

${element.username}

+

${element.input_octets}

+

${element.output_octets}

+

${element.calling_station_id}

+

${element.start_time}

+

${element.stop_time}

+ ` + ); + }); + $('#no-session-msg').hide(); + $('#device-radius-sessions-table').show(); + $('#view-all-radius-session-wrapper').show(); + } + }); + } + $(document).on('tabshown', function (e) { + if (e.tabId === '#radius-sessions') { + fetchRadiusSessions(); + } + }); + if (window.location.hash == '#radius-sessions') { + $.event.trigger({ + type: 'tabshown', + tabId: window.location.hash, + }); + } + }); +}(django.jQuery)); diff --git a/openwisp_radius/integrations/monitoring/tasks.py b/openwisp_radius/integrations/monitoring/tasks.py new file mode 100644 index 00000000..f8aef603 --- /dev/null +++ b/openwisp_radius/integrations/monitoring/tasks.py @@ -0,0 +1,256 @@ +import logging + +from celery import shared_task +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType +from django.db.models import Count, Q +from django.utils import timezone +from swapper import load_model + +from . import settings as app_settings +from .utils import clean_registration_method, sha1_hash + +Metric = load_model('monitoring', 'Metric') +Chart = load_model('monitoring', 'Chart') +RegisteredUser = load_model('openwisp_radius', 'RegisteredUser') +RadiusAccounting = load_model('openwisp_radius', 'RadiusAccounting') +OrganizationUser = load_model('openwisp_users', 'OrganizationUser') +Device = load_model('config', 'Device') +DeviceLocation = load_model('geo', 'Location') +User = get_user_model() + +logger = logging.getLogger(__name__) + + +def _get_user_signup_metric(organization_id, registration_method): + metric, _ = Metric._get_or_create( + configuration='user_signups', + name='User SignUps', + key='user_signups', + object_id=None, + content_type=None, + extra_tags={ + 'organization_id': str(organization_id), + 'method': registration_method, + }, + ) + return metric + + +def _get_total_user_signup_metric(organization_id, registration_method): + metric, _ = Metric._get_or_create( + configuration='tot_user_signups', + name='Total User SignUps', + key='tot_user_signups', + object_id=None, + content_type=None, + extra_tags={ + 'organization_id': str(organization_id), + 'method': registration_method, + }, + ) + return metric + + +def _write_user_signup_metric_for_all(metric_key): + metric_data = [] + start_time = timezone.now() - timezone.timedelta(hours=1) + end_time = timezone.now() + if metric_key == 'user_signups': + get_metric_func = _get_user_signup_metric + else: + get_metric_func = _get_total_user_signup_metric + # Get the total number of registered users + registered_user_query = RegisteredUser.objects.exclude( + user__date_joined__gt=end_time, + ) + if metric_key == 'user_signups': + registered_user_query = registered_user_query.filter( + user__date_joined__gt=start_time, + user__date_joined__lte=end_time, + ) + total_registered_users = dict( + registered_user_query.values_list('method').annotate( + count=Count('user', distinct=True) + ) + ) + # Some manually created users, like superuser may not have a + # RegisteredUser object. We would could them with "unspecified" method + users_without_registereduser_query = User.objects.filter( + registered_user__isnull=True + ) + if metric_key == 'user_signups': + users_without_registereduser_query = users_without_registereduser_query.filter( + date_joined__gt=start_time, + date_joined__lte=end_time, + ) + users_without_registereduser = users_without_registereduser_query.count() + + # Add the number of users which do not have a related RegisteredUser + # to the number of users which registered using "unspecified" method. + try: + total_registered_users[''] = ( + total_registered_users[''] + users_without_registereduser + ) + except KeyError: + total_registered_users[''] = users_without_registereduser + + for method, count in total_registered_users.items(): + method = clean_registration_method(method) + metric = get_metric_func(organization_id='__all__', registration_method=method) + metric_data.append((metric, {'value': count})) + Metric.batch_write(metric_data) + + +def _write_user_signup_metrics_for_orgs(metric_key): + metric_data = [] + start_time = timezone.now() - timezone.timedelta(hours=1) + end_time = timezone.now() + if metric_key == 'user_signups': + get_metric_func = _get_user_signup_metric + else: + get_metric_func = _get_total_user_signup_metric + + # Get the registration data for the past hour. + # The query returns a tuple of organization_id, registration_method and + # count of users who registered with that organization and method. + registered_users_query = RegisteredUser.objects.exclude( + user__openwisp_users_organizationuser__created__gt=end_time, + ) + + if metric_key == 'user_signups': + registered_users_query = registered_users_query.filter( + user__openwisp_users_organizationuser__created__gt=start_time, + user__openwisp_users_organizationuser__created__lte=end_time, + ) + registered_users = registered_users_query.values_list( + 'user__openwisp_users_organizationuser__organization_id', 'method' + ).annotate(count=Count('user_id', distinct=True)) + + # There could be users which were manually created (e.g. superuser) + # which do not have related RegisteredUser object. Add the count + # of such users with the "unspecified" method. + users_without_registereduser_query = OrganizationUser.objects.filter( + user__registered_user__isnull=True + ) + if metric_key == 'user_signups': + users_without_registereduser_query = users_without_registereduser_query.filter( + created__gt=start_time, created__lte=end_time + ) + users_without_registereduser = dict( + users_without_registereduser_query.values_list('organization_id').annotate( + count=Count('user_id', distinct=True) + ) + ) + + for org_id, registration_method, count in registered_users: + registration_method = clean_registration_method(registration_method) + if registration_method == 'unspecified': + count += users_without_registereduser.get(org_id, 0) + metric = get_metric_func( + organization_id=org_id, registration_method=registration_method + ) + metric_data.append((metric, {'value': count})) + Metric.batch_write(metric_data) + + +@shared_task +def write_user_registration_metrics(): + """ + This task is expected to be executed hourly. + + This task writes user registration metrics to the InfluxDB. + It writes to the following metrics: + - User Signups: This shows the number of new users who + have registered using different methods + - Total User Signups: This shows the total number of + users registered using different methods + """ + _write_user_signup_metric_for_all(metric_key='user_signups') + _write_user_signup_metric_for_all(metric_key='tot_user_signups') + _write_user_signup_metrics_for_orgs(metric_key='user_signups') + _write_user_signup_metrics_for_orgs(metric_key='tot_user_signups') + + +@shared_task +def post_save_radiusaccounting( + username, + organization_id, + input_octets, + output_octets, + calling_station_id, + called_station_id, + time=None, +): + try: + registration_method = ( + RegisteredUser.objects.only('method').get(user__username=username).method + ) + except RegisteredUser.DoesNotExist: + logger.info( + f'RegisteredUser object not found for "{username}".' + ' The metric will be written with "unspecified" registration method!' + ) + registration_method = 'unspecified' + else: + registration_method = clean_registration_method(registration_method) + device_lookup = Q(mac_address__iexact=called_station_id.replace('-', ':')) + # Do not use organization_id for device lookup if shared accounting is enabled + if not app_settings.SHARED_ACCOUNTING: + device_lookup &= Q(organization_id=organization_id) + try: + device = ( + Device.objects.select_related('devicelocation') + .only('id', 'organization_id', 'devicelocation__location_id') + .get(device_lookup) + ) + except Device.DoesNotExist: + logger.warning( + f'Device object not found with MAC "{called_station_id}"' + f' and organization "{organization_id}".' + ' The metric will be written without a related object!' + ) + object_id = None + content_type = None + location_id = None + if app_settings.SHARED_ACCOUNTING: + organization_id = None + else: + object_id = str(device.id) + content_type = ContentType.objects.get_for_model(Device) + if hasattr(device, 'devicelocation'): + location_id = str(device.devicelocation.location_id) + else: + location_id = None + + metric, created = Metric._get_or_create( + configuration='radius_acc', + name='RADIUS Accounting', + key='radius_acc', + object_id=object_id, + content_type=content_type, + extra_tags={ + 'organization_id': organization_id, + 'method': registration_method, + 'calling_station_id': sha1_hash(calling_station_id), + 'called_station_id': called_station_id, + 'location_id': location_id, + }, + ) + metric.write( + input_octets, + extra_values={ + 'output_octets': output_octets, + 'username': sha1_hash(username), + }, + time=time, + ) + if not object_id: + # Adding a chart requires all parameters of extra_tags to be present. + # A chart cannot be created without object_id and content_type. + return + if created: + for configuration in metric.config_dict['charts'].keys(): + chart = Chart(metric=metric, configuration=configuration) + chart.full_clean() + chart.save() 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 new file mode 100644 index 00000000..e5d1fd80 --- /dev/null +++ b/openwisp_radius/integrations/monitoring/templates/admin/config/radius-monitoring/device/change_form.html @@ -0,0 +1,42 @@ +{% extends "admin/config/device/change_form.html" %} +{% load i18n %} + +{% block inline_field_sets %} +{{ block.super }} +{% comment %} +RadiusAccounting model does not have a ForeignKey to Device object. +Therefore, RadiusAccounting inline is added using HTML and JS. +{% endcomment %} +{% if not add %} +
+
+

{% trans "There are no RADIUS sessions for this device." %}

+ +
+ +{% endif %} +{% endblock %} diff --git a/openwisp_radius/integrations/monitoring/tests/__init__.py b/openwisp_radius/integrations/monitoring/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openwisp_radius/integrations/monitoring/tests/mixins.py b/openwisp_radius/integrations/monitoring/tests/mixins.py new file mode 100644 index 00000000..c7e3dce3 --- /dev/null +++ b/openwisp_radius/integrations/monitoring/tests/mixins.py @@ -0,0 +1,88 @@ +from uuid import uuid4 + +from openwisp_monitoring.db import timeseries_db +from openwisp_monitoring.db.backends import TIMESERIES_DB +from openwisp_monitoring.device.utils import manage_short_retention_policy +from swapper import load_model + + +class CreateDeviceMonitoringMixin(object): + TEST_MAC_ADDRESS = '00:11:22:33:44:55' + ORIGINAL_DB = TIMESERIES_DB['NAME'] + TEST_DB = f'{ORIGINAL_DB}_test' + + @classmethod + def setUpClass(cls): + # By default timeseries_db.db shall connect to the database + # defined in settings when apps are loaded. We don't want that while testing + timeseries_db.db_name = cls.TEST_DB + del timeseries_db.db + del timeseries_db.dbs + timeseries_db.create_database() + manage_short_retention_policy() + super().setUpClass() + + @classmethod + def tearDownClass(cls): + timeseries_db.drop_database() + super().tearDownClass() + + def tearDown(self): + timeseries_db.delete_metric_data() + super().tearDown() + + @property + def device_model(self): + return load_model('config', 'Device') + + @property + def metric_model(self): + return load_model('monitoring', 'Metric') + + @property + def chart_model(self): + return load_model('monitoring', 'Chart') + + @property + def location_model(self): + return load_model('geo', 'Location') + + @property + def object_location_model(self): + return load_model('geo', 'DeviceLocation') + + def _create_device(self, **kwargs): + options = dict( + name='default.test.device', + organization=self._get_org(), + mac_address=self.TEST_MAC_ADDRESS, + hardware_id=str(uuid4().hex), + model='TP-Link TL-WDR4300 v1', + os='LEDE Reboot 17.01-SNAPSHOT r3313-c2999ef', + ) + options.update(kwargs) + d = self.device_model(**options) + d.full_clean() + d.save() + return d + + def _create_location(self, **kwargs): + options = dict( + name='test-location', + address='Via del Corso, Roma, Italia', + geometry='SRID=4326;POINT (12.512124 41.898903)', + type='outdoor', + ) + options.update(kwargs) + location = self.location_model(**options) + location.full_clean() + location.save() + return location + + def _create_device_location(self, **kwargs): + options = dict() + options.update(kwargs) + device_location = self.object_location_model(**options) + device_location.full_clean() + device_location.save() + return device_location diff --git a/openwisp_radius/integrations/monitoring/tests/test_admin.py b/openwisp_radius/integrations/monitoring/tests/test_admin.py new file mode 100644 index 00000000..1e41dc6a --- /dev/null +++ b/openwisp_radius/integrations/monitoring/tests/test_admin.py @@ -0,0 +1,62 @@ +from django.test import TestCase, tag +from django.urls import reverse +from django.utils import timezone + +from openwisp_radius.tests import _RADACCT, CreateRadiusObjectsMixin + +from ..utils import get_datetime_filter_start_date, get_datetime_filter_stop_date +from .mixins import CreateDeviceMonitoringMixin + + +@tag('radius_monitoring') +class TestDeviceAdmin(CreateRadiusObjectsMixin, CreateDeviceMonitoringMixin, TestCase): + app_label = 'config' + + def setUp(self): + admin = self._create_admin() + self.client.force_login(admin) + + def test_radius_session_tab(self): + device = self._create_device() + response = self.client.get( + reverse(f'admin:{self.app_label}_device_change', args=[device.id]) + ) + self.assertEqual(response.status_code, 200) + self.assertContains( + response, + '
', + ) + self.assertContains( + response, + '', + ) + + def test_radius_dashboard_chart_data(self): + options = _RADACCT.copy() + options['unique_id'] = '117' + options['start_time'] = timezone.now().strftime('%Y-%m-%d 00:00:00') + options['input_octets'] = '1234567890' + self._create_radius_accounting(**options) + response = self.client.get(reverse('admin:index')) + self.assertEqual(response.status_code, 200) + start_time = get_datetime_filter_start_date() + end_time = get_datetime_filter_stop_date() + self.assertContains( + response, + ( + '{\'name\': "Today\'s RADIUS sessions", \'query_params\': ' + '{\'values\': [1], \'labels\': [\'Open\']}, \'colors\': ' + '[\'#267126\'], \'filters\': [\'True\'], \'labels\': ' + '{\'open\': \'Open\', \'closed\': \'Closed\'}, \'target_link\': ' + '\'/admin/openwisp_radius/radiusaccounting/?start_time__gte=' + f'{start_time}&start_time__lt={end_time}' + '&stop_time__isnull=\'}, 31: {\'name\': "Today\'s RADIUS traffic (GB)",' + ' \'query_params\': {\'values\': [1.0], \'labels\': ' + '[\'Download traffic (GB)\']}, \'colors\': [\'#1f77b4\'], \'labels\': ' + '{\'download_traffic\': \'Download traffic (GB)\', \'upload_traffic\': ' + '\'Upload traffic (GB)\'}, \'filtering\': \'False\', \'target_link\': ' + '\'/admin/openwisp_radius/radiusaccounting/?start_time__gte=' + f'{start_time}&start_time__lt={end_time}\'' + '}' + ), + ) diff --git a/openwisp_radius/integrations/monitoring/tests/test_metrics.py b/openwisp_radius/integrations/monitoring/tests/test_metrics.py new file mode 100644 index 00000000..336c5167 --- /dev/null +++ b/openwisp_radius/integrations/monitoring/tests/test_metrics.py @@ -0,0 +1,443 @@ +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType +from django.core.cache import cache +from django.test import tag +from swapper import load_model + +from openwisp_radius.tests import _RADACCT +from openwisp_radius.tests.mixins import BaseTransactionTestCase + +from ..migrations import create_general_metrics +from ..utils import sha1_hash +from .mixins import CreateDeviceMonitoringMixin + +TASK_PATH = 'openwisp_radius.integrations.monitoring.tasks' + +RegisteredUser = load_model('openwisp_radius', 'RegisteredUser') +User = get_user_model() + + +@tag('radius_monitoring') +class TestMetrics(CreateDeviceMonitoringMixin, BaseTransactionTestCase): + def _create_registered_user(self, **kwargs): + options = {'is_verified': False, 'method': 'mobile_phone'} + options.update(**kwargs) + if 'user' not in options: + options['user'] = self._create_user() + reg_user = RegisteredUser(**options) + reg_user.full_clean() + reg_user.save() + return reg_user + + @patch('logging.Logger.warning') + def test_post_save_radiusaccounting(self, *args): + user = self._create_user() + reg_user = self._create_registered_user(user=user) + device = self._create_device() + device_loc = self._create_device_location( + content_object=device, + location=self._create_location(organization=device.organization), + ) + options = _RADACCT.copy() + options.update( + { + 'unique_id': '117', + 'username': user.username, + 'called_station_id': device.mac_address.replace('-', ':').upper(), + 'calling_station_id': '00:00:00:00:00:00', + 'input_octets': '8000000000', + 'output_octets': '9000000000', + } + ) + options['stop_time'] = options['start_time'] + + self._create_radius_accounting(**options) + self.assertEqual( + self.metric_model.objects.filter( + configuration='radius_acc', + name='RADIUS Accounting', + key='radius_acc', + object_id=str(device.id), + content_type=ContentType.objects.get_for_model(self.device_model), + extra_tags={ + 'called_station_id': device.mac_address, + 'calling_station_id': sha1_hash('00:00:00:00:00:00'), + 'location_id': str(device_loc.location.id), + 'method': reg_user.method, + 'organization_id': str(self.default_org.id), + }, + ).count(), + 1, + ) + metric = self.metric_model.objects.filter(configuration='radius_acc').first() + traffic_chart = metric.chart_set.get(configuration='radius_traffic') + points = traffic_chart.read() + self.assertEqual(points['traces'][0][0], 'download') + self.assertEqual(points['traces'][0][1][-1], 8) + self.assertEqual(points['traces'][1][0], 'upload') + self.assertEqual(points['traces'][1][1][-1], 9) + self.assertEqual(points['summary'], {'upload': 9, 'download': 8}) + + session_chart = metric.chart_set.get(configuration='rad_session') + points = session_chart.read() + self.assertEqual(points['traces'][0][0], 'mobile_phone') + self.assertEqual(points['traces'][0][1][-1], 1) + self.assertEqual(points['summary'], {'mobile_phone': 1}) + + @patch('openwisp_radius.integrations.monitoring.tasks.post_save_radiusaccounting') + def test_post_save_radiusaccouting_open_session(self, mocked_task): + radius_options = _RADACCT.copy() + radius_options['unique_id'] = '117' + session = self._create_radius_accounting(**radius_options) + self.assertEqual(session.stop_time, None) + mocked_task.assert_not_called() + + @patch('logging.Logger.warning') + def test_post_save_radius_accounting_shared_accounting(self, mocked_logger): + """ + This test ensures that the metric is written with the device's MAC address + when the OPENWISP_RADIUS_MONITORING_SHARED_ACCOUNTING is + set to True, even if the RadiusAccounting session and the related device + have different organizations. + """ + from .. import settings as app_settings + + user = self._create_user() + reg_user = self._create_registered_user(user=user) + org2 = self._get_org('org2') + device = self._create_device(organization=org2) + device_loc = self._create_device_location( + content_object=device, + location=self._create_location(organization=device.organization), + ) + options = _RADACCT.copy() + options.update( + { + 'unique_id': '117', + 'username': user.username, + 'called_station_id': device.mac_address.replace('-', ':').upper(), + 'calling_station_id': '00:00:00:00:00:00', + 'input_octets': '8000000000', + 'output_octets': '9000000000', + } + ) + options['stop_time'] = options['start_time'] + device_metric_qs = self.metric_model.objects.filter( + configuration='radius_acc', + name='RADIUS Accounting', + key='radius_acc', + object_id=str(device.id), + content_type=ContentType.objects.get_for_model(self.device_model), + extra_tags={ + 'called_station_id': device.mac_address, + 'calling_station_id': sha1_hash('00:00:00:00:00:00'), + 'location_id': str(device_loc.location.id), + 'method': reg_user.method, + 'organization_id': str(self.default_org.id), + }, + ) + + with self.subTest('Test SHARED_ACCOUNTING is set to False'): + with patch.object(app_settings, 'SHARED_ACCOUNTING', False): + self._create_radius_accounting(**options) + self.assertEqual( + device_metric_qs.count(), + 0, + ) + # The metric is created without the device_id + self.assertEqual( + self.metric_model.objects.filter( + configuration='radius_acc', + name='RADIUS Accounting', + key='radius_acc', + object_id=None, + content_type=None, + extra_tags={ + 'called_station_id': device.mac_address, + 'calling_station_id': sha1_hash('00:00:00:00:00:00'), + 'location_id': None, + 'method': reg_user.method, + 'organization_id': str(self.default_org.id), + }, + ).count(), + 1, + ) + + with self.subTest('Test SHARED_ACCOUNTING is set to True'): + with patch.object(app_settings, 'SHARED_ACCOUNTING', True): + options['unique_id'] = '118' + self._create_radius_accounting(**options) + self.assertEqual( + device_metric_qs.count(), + 1, + ) + metric = device_metric_qs.first() + self.assertEqual( + metric.extra_tags['organization_id'], str(self.default_org.id) + ) + traffic_chart = metric.chart_set.get(configuration='radius_traffic') + points = traffic_chart.read() + self.assertEqual(points['traces'][0][0], 'download') + self.assertEqual(points['traces'][0][1][-1], 8) + self.assertEqual(points['traces'][1][0], 'upload') + self.assertEqual(points['traces'][1][1][-1], 9) + self.assertEqual(points['summary'], {'upload': 9, 'download': 8}) + + @patch('logging.Logger.warning') + def test_post_save_radius_accounting_device_not_found(self, mocked_logger): + """ + This test checks that radius accounting metric is created + even if the device could not be found with the called_station_id. + This scenario can happen on an installations which uses the + convert_called_station_id feature, but it is not configured + properly leaving all called_station_id unconverted. + """ + user = self._create_user() + reg_user = self._create_registered_user(user=user) + options = _RADACCT.copy() + options.update( + { + 'unique_id': '117', + 'username': user.username, + 'called_station_id': '11:22:33:44:55:66', + 'calling_station_id': '00:00:00:00:00:00', + 'input_octets': '8000000000', + 'output_octets': '9000000000', + } + ) + options['stop_time'] = options['start_time'] + # Remove calls for user registration from mocked logger + mocked_logger.reset_mock() + + self._create_radius_accounting(**options) + self.assertEqual( + self.metric_model.objects.filter( + configuration='radius_acc', + name='RADIUS Accounting', + key='radius_acc', + object_id=None, + content_type=None, + extra_tags={ + 'called_station_id': '11:22:33:44:55:66', + 'calling_station_id': sha1_hash('00:00:00:00:00:00'), + 'location_id': None, + 'method': reg_user.method, + 'organization_id': str(self.default_org.id), + }, + ).count(), + 1, + ) + # The TransactionTestCase truncates all the data after each test. + # The general metrics and charts which are created by migrations + # get deleted after each test. Therefore, we create them again here. + create_general_metrics(None, None) + metric = self.metric_model.objects.filter(configuration='radius_acc').first() + # A dedicated chart for this metric was not created since the + # related device was not identified by the called_station_id. + # The data however can be retrieved from the general charts. + self.assertEqual(metric.chart_set.count(), 0) + general_traffic_chart = self.chart_model.objects.get( + configuration='gen_rad_traffic' + ) + points = general_traffic_chart.read() + self.assertEqual(points['traces'][0][0], 'download') + self.assertEqual(points['traces'][0][1][-1], 8) + self.assertEqual(points['traces'][1][0], 'upload') + self.assertEqual(points['traces'][1][1][-1], 9) + self.assertEqual(points['summary'], {'upload': 9, 'download': 8}) + + general_session_chart = self.chart_model.objects.get( + configuration='gen_rad_session' + ) + points = general_session_chart.read() + self.assertEqual(points['traces'][0][0], 'mobile_phone') + self.assertEqual(points['traces'][0][1][-1], 1) + self.assertEqual(points['summary'], {'mobile_phone': 1}) + mocked_logger.assert_called_once_with( + f'Device object not found with MAC "{options["called_station_id"]}"' + f' and organization "{self.default_org.id}".' + ' The metric will be written without a related object!' + ) + + @patch('logging.Logger.info') + def test_post_save_radius_accounting_registereduser_not_found(self, mocked_logger): + """ + This test checks that radius accounting metric is created + even if the RegisteredUser object could not be found for the user. + This scenario can happen on an installations which do not require + users to signup to access the internet/ + """ + user = self._create_user() + device = self._create_device() + device_loc = self._create_device_location( + content_object=device, + location=self._create_location(organization=device.organization), + ) + options = _RADACCT.copy() + options.update( + { + 'unique_id': '117', + 'username': user.username, + 'called_station_id': device.mac_address.replace('-', ':').upper(), + 'calling_station_id': '00:00:00:00:00:00', + 'input_octets': '8000000000', + 'output_octets': '9000000000', + } + ) + options['stop_time'] = options['start_time'] + + self._create_radius_accounting(**options) + self.assertEqual( + self.metric_model.objects.filter( + configuration='radius_acc', + name='RADIUS Accounting', + key='radius_acc', + object_id=str(device.id), + content_type=ContentType.objects.get_for_model(self.device_model), + extra_tags={ + 'called_station_id': device.mac_address, + 'calling_station_id': sha1_hash('00:00:00:00:00:00'), + 'location_id': str(device_loc.location.id), + 'method': 'unspecified', + 'organization_id': str(self.default_org.id), + }, + ).count(), + 1, + ) + metric = self.metric_model.objects.filter(configuration='radius_acc').first() + traffic_chart = metric.chart_set.get(configuration='radius_traffic') + points = traffic_chart.read() + self.assertEqual(points['traces'][0][0], 'download') + self.assertEqual(points['traces'][0][1][-1], 8) + self.assertEqual(points['traces'][1][0], 'upload') + self.assertEqual(points['traces'][1][1][-1], 9) + self.assertEqual(points['summary'], {'upload': 9, 'download': 8}) + + session_chart = metric.chart_set.get(configuration='rad_session') + points = session_chart.read() + self.assertEqual(points['traces'][0][0], 'unspecified') + self.assertEqual(points['traces'][0][1][-1], 1) + self.assertEqual(points['summary'], {'unspecified': 1}) + mocked_logger.assert_called_once_with( + f'RegisteredUser object not found for "{user.username}".' + ' The metric will be written with "unspecified" registration method!' + ) + + def test_write_user_registration_metrics(self): + from ..tasks import write_user_registration_metrics + + def _read_chart(chart, **kwargs): + return chart.read( + additional_query_kwargs={'additional_params': kwargs}, + ) + + # The TransactionTestCase truncates all the data after each test. + # The general metrics and charts which are created by migrations + # get deleted after each test. Therefore, we create them again here. + # The "Metric._get_metric" caches the metric, this interferes with + # create_general_metrics, hence we clear the cache here. + cache.clear() + create_general_metrics(None, None) + org = self._get_org() + user_signup_metric = self.metric_model.objects.get(key='user_signups') + total_user_signup_metric = self.metric_model.objects.get(key='tot_user_signups') + with self.subTest( + 'User does not has OrganizationUser and RegisteredUser object' + ): + self._get_admin() + write_user_registration_metrics.delay() + + user_signup_chart = user_signup_metric.chart_set.first() + all_points = _read_chart(user_signup_chart, organization_id=['__all__']) + self.assertEqual(all_points['traces'][0][0], 'unspecified') + self.assertEqual(all_points['traces'][0][1][-1], 1) + self.assertEqual(all_points['summary'], {'unspecified': 1}) + org_points = _read_chart(user_signup_chart, organization_id=[str(org.id)]) + self.assertEqual(len(org_points['traces']), 0) + + total_user_signup_chart = total_user_signup_metric.chart_set.first() + all_points = _read_chart( + total_user_signup_chart, organization_id=['__all__'] + ) + self.assertEqual(all_points['traces'][0][0], 'unspecified') + self.assertEqual(all_points['traces'][0][1][-1], 1) + self.assertEqual(all_points['summary'], {'unspecified': 1}) + org_points = _read_chart( + total_user_signup_chart, organization_id=[str(org.id)] + ) + self.assertEqual(len(org_points['traces']), 0) + + self.metric_model.post_delete_receiver(user_signup_metric) + self.metric_model.post_delete_receiver(total_user_signup_metric) + User.objects.all().delete() + + with self.subTest('User has OrganizationUser but no RegisteredUser object'): + user = self._create_org_user(organization=org).user + write_user_registration_metrics.delay() + + user_signup_chart = user_signup_metric.chart_set.first() + all_points = _read_chart(user_signup_chart, organization_id=['__all__']) + self.assertEqual(all_points['traces'][0][0], 'unspecified') + self.assertEqual(all_points['traces'][0][1][-1], 1) + self.assertEqual(all_points['summary'], {'unspecified': 1}) + org_points = _read_chart(user_signup_chart, organization_id=[str(org.id)]) + self.assertEqual(all_points['traces'][0][0], 'unspecified') + self.assertEqual(all_points['traces'][0][1][-1], 1) + self.assertEqual(all_points['summary'], {'unspecified': 1}) + + total_user_signup_chart = total_user_signup_metric.chart_set.first() + all_points = _read_chart( + total_user_signup_chart, organization_id=['__all__'] + ) + self.assertEqual(all_points['traces'][0][0], 'unspecified') + self.assertEqual(all_points['traces'][0][1][-1], 1) + self.assertEqual(all_points['summary'], {'unspecified': 1}) + org_points = _read_chart( + total_user_signup_chart, organization_id=[str(org.id)] + ) + self.assertEqual(all_points['traces'][0][0], 'unspecified') + self.assertEqual(all_points['traces'][0][1][-1], 1) + self.assertEqual(all_points['summary'], {'unspecified': 1}) + + self.metric_model.post_delete_receiver(user_signup_metric) + self.metric_model.post_delete_receiver(total_user_signup_metric) + + with self.subTest( + 'Test user has both OrganizationUser and RegisteredUser object' + ): + self._create_registered_user(user=user) + write_user_registration_metrics.delay() + + user_signup_chart = user_signup_metric.chart_set.first() + all_points = _read_chart(user_signup_chart, organization_id=['__all__']) + self.assertEqual(all_points['traces'][0][0], 'mobile_phone') + self.assertEqual(all_points['traces'][0][1][-1], 1) + self.assertEqual( + all_points['summary'], {'mobile_phone': 1, 'unspecified': 0} + ) + org_points = _read_chart(user_signup_chart, organization_id=[str(org.id)]) + self.assertEqual(all_points['traces'][0][0], 'mobile_phone') + self.assertEqual(all_points['traces'][0][1][-1], 1) + self.assertEqual( + all_points['summary'], {'mobile_phone': 1, 'unspecified': 0} + ) + + total_user_signup_chart = total_user_signup_metric.chart_set.first() + org_points = _read_chart( + total_user_signup_chart, organization_id=['__all__'] + ) + self.assertEqual(org_points['traces'][0][0], 'mobile_phone') + self.assertEqual(org_points['traces'][0][1][-1], 1) + self.assertEqual( + org_points['summary'], {'mobile_phone': 1, 'unspecified': 0} + ) + org_points = _read_chart( + total_user_signup_chart, organization_id=[str(org.id)] + ) + self.assertEqual(all_points['traces'][0][0], 'mobile_phone') + self.assertEqual(all_points['traces'][0][1][-1], 1) + self.assertEqual( + all_points['summary'], {'mobile_phone': 1, 'unspecified': 0} + ) diff --git a/openwisp_radius/integrations/monitoring/utils.py b/openwisp_radius/integrations/monitoring/utils.py new file mode 100644 index 00000000..dbbb3375 --- /dev/null +++ b/openwisp_radius/integrations/monitoring/utils.py @@ -0,0 +1,36 @@ +import hashlib +from datetime import datetime, timedelta + +from django.utils import timezone + +local_timezone = timezone.get_current_timezone() + + +def _get_formatted_datetime_string(date_time): + return ( + str(datetime.combine(date_time, datetime.min.time()).astimezone(local_timezone)) + .replace(' ', '+') + .replace(':', '%3A') + ) + + +def get_datetime_filter_start_date(): + start_date = timezone.localdate() + return _get_formatted_datetime_string(start_date) + + +def get_datetime_filter_stop_date(): + stop_date = timezone.localdate() + timedelta(days=1) + return _get_formatted_datetime_string(stop_date) + + +def sha1_hash(input_string): + sha1 = hashlib.sha1() + sha1.update(input_string.encode('utf-8')) + return sha1.hexdigest() + + +def clean_registration_method(method): + if method == '': + method = 'unspecified' + return method diff --git a/openwisp_radius/registration.py b/openwisp_radius/registration.py index e797e4fe..66d26921 100644 --- a/openwisp_radius/registration.py +++ b/openwisp_radius/registration.py @@ -8,8 +8,8 @@ REGISTRATION_METHOD_CHOICES = [ ('', 'Unspecified'), ('manual', _('Manually created')), - ('email', _('Email (No Identity Verification)')), - ('mobile_phone', _('Mobile phone verification')), + ('email', _('Email')), + ('mobile_phone', _('Mobile phone')), ] AUTHORIZE_UNVERIFIED = [] diff --git a/openwisp_radius/static/openwisp-radius/js/mode-switcher.js b/openwisp_radius/static/openwisp-radius/js/mode-switcher.js index 37f9f44b..780844fc 100644 --- a/openwisp_radius/static/openwisp-radius/js/mode-switcher.js +++ b/openwisp_radius/static/openwisp-radius/js/mode-switcher.js @@ -7,7 +7,7 @@ '.field-user, .field-username)'), guided = $('.field-group, .field-user'), custom = $('.field-groupname, .field-username'); - mode.change(function (e) { + mode.change(function () { allExceptMode.hide(); if (mode.val() === 'guided') { guided.show(); diff --git a/openwisp_radius/static/openwisp-radius/js/strategy-switcher.js b/openwisp_radius/static/openwisp-radius/js/strategy-switcher.js index d24a01ad..e070bf91 100644 --- a/openwisp_radius/static/openwisp-radius/js/strategy-switcher.js +++ b/openwisp_radius/static/openwisp-radius/js/strategy-switcher.js @@ -35,7 +35,7 @@ } } - strategy.change(function (e) { + strategy.change(function () { if (strategy.val() === 'prefix') { prefix_strategy(); } else if (strategy.val() === 'csv') { diff --git a/openwisp_radius/tests/test_api/test_api.py b/openwisp_radius/tests/test_api/test_api.py index 1e3bc997..b272d8e6 100644 --- a/openwisp_radius/tests/test_api/test_api.py +++ b/openwisp_radius/tests/test_api/test_api.py @@ -39,6 +39,7 @@ OrganizationRadiusSettings = load_model('OrganizationRadiusSettings') Organization = swapper.load_model('openwisp_users', 'Organization') OrganizationUser = swapper.load_model('openwisp_users', 'OrganizationUser') +Group = swapper.load_model('openwisp_users', 'Group') START_DATE = '2019-04-20T22:14:09+01:00' @@ -1173,6 +1174,141 @@ def test_user_radius_usage_view(self): response = self.client.get(usage_url, HTTP_AUTHORIZATION=authorization) self.assertEqual(response.status_code, 404) + def test_user_group_check_serializer_counter_does_not_exist(self): + group = self._create_radius_group(name='group name') + group_check = self._create_radius_groupcheck( + attribute='ChilliSpot-Max-Input-Octets', + op=':=', + value='2000000000', + group=group, + groupname=group.name, + ) + serializer = UserGroupCheckSerializer(group_check) + self.assertDictEqual( + serializer.data, + { + 'attribute': 'ChilliSpot-Max-Input-Octets', + 'op': ':=', + 'result': None, + 'type': None, + 'value': '2000000000', + }, + ) + + def test_radius_accounting(self): + path = reverse('radius:radius_accounting_list') + org1 = self.default_org + org2 = self._create_org(name='org2', slug='org2') + data1 = self.acct_post_data + data1.update( + dict( + session_id='35000006', + unique_id='75058e50', + input_octets=9900909, + output_octets=1513075509, + username='tester', + organization=org1, + calling_station_id='11:22:33:44:55:66', + called_station_id='AA:BB:CC:DD:EE:FF', + ) + ) + self._create_radius_accounting(**data1) + data2 = self.acct_post_data + data2.update( + dict( + session_id='40111116', + unique_id='12234f69', + input_octets=3000909, + output_octets=1613176609, + username='tester', + organization=org1, + calling_station_id='11-22-33-44-55-66', + called_station_id='AA-BB-CC-DD-EE-FF', + ) + ) + self._create_radius_accounting(**data2) + data3 = self.acct_post_data + data3.update( + dict( + session_id='89897654', + unique_id='99144d60', + input_octets=4440909, + output_octets=1119074409, + username='admin', + organization=org2, + ) + ) + self._create_radius_accounting(**data3) + + with self.subTest('Test unauthenicated user'): + response = self.client.get(path) + self.assertEqual(response.status_code, 401) + + org1_user = self._create_org_user(organization=org1, is_admin=False) + administrator = Group.objects.get(name='Administrator') + org1_user.user.groups.add(administrator) + self.client.force_login(org1_user.user) + with self.subTest('Test organization user (not organization manager)'): + response = self.client.get(path) + self.assertEqual(response.status_code, 403) + + org1_user.is_admin = True + org1_user.save() + with self.subTest('Test organization user (organization manager)'): + response = self.client.get(path) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 2) + self.assertEqual(response.data[0]['unique_id'], data2['unique_id']) + self.assertEqual(response.data[1]['unique_id'], data1['unique_id']) + + with self.subTest('Test superuser can view all sessions'): + admin = self._create_admin() + self.client.force_login(admin) + response = self.client.get(path) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 3) + self.assertEqual(response.data[0]['unique_id'], data3['unique_id']) + self.assertEqual(response.data[1]['unique_id'], data2['unique_id']) + self.assertEqual(response.data[2]['unique_id'], data1['unique_id']) + + with self.subTest('Test filtering with called_station_id'): + response = self.client.get(path, {'called_station_id': 'AA-BB-CC-DD-EE-FF'}) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 2) + self.assertEqual(response.data[0]['called_station_id'], 'AA-BB-CC-DD-EE-FF') + self.assertEqual(response.data[1]['called_station_id'], 'AA:BB:CC:DD:EE:FF') + + response = self.client.get(path, {'called_station_id': 'AA:BB:CC:DD:EE:FF'}) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 2) + self.assertEqual(response.data[0]['called_station_id'], 'AA-BB-CC-DD-EE-FF') + self.assertEqual(response.data[1]['called_station_id'], 'AA:BB:CC:DD:EE:FF') + + with self.subTest('Test filtering with calling_station_id'): + response = self.client.get( + path, {'calling_station_id': '11-22-33-44-55-66'} + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 2) + self.assertEqual( + response.data[0]['calling_station_id'], '11-22-33-44-55-66' + ) + self.assertEqual( + response.data[1]['calling_station_id'], '11:22:33:44:55:66' + ) + + response = self.client.get( + path, {'calling_station_id': '11:22:33:44:55:66'} + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 2) + self.assertEqual( + response.data[0]['calling_station_id'], '11-22-33-44-55-66' + ) + self.assertEqual( + response.data[1]['calling_station_id'], '11:22:33:44:55:66' + ) + del BaseTestCase del BaseTransactionTestCase diff --git a/pyproject.toml b/pyproject.toml index 27522675..b6130200 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,5 @@ [tool.coverage.run] source = ["openwisp_radius"] -parallel = true concurrency = ["multiprocessing"] omit = [ "openwisp_radius/__init__.py", diff --git a/requirements-test.txt b/requirements-test.txt index 0bfcf4a7..9872eb49 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -3,9 +3,13 @@ djangorestframework-simplejwt django-cors-headers>=2.5.2 packaging openwisp-sphinx-theme~=1.0.2 -freezegun +freezegun~=1.1.0 django-extensions -openwisp-utils[qa]~=1.1.0 +openwisp-utils[qa] @ https://github.com/openwisp/openwisp-utils/tarball/master pylinkvalidator lxml~=5.3.0 cssselect~=1.2.0 +openwisp-monitoring @ https://github.com/openwisp/openwisp-monitoring/tarball/master +django-redis~=5.2.0 +mock-ssh-server~=0.9.0 +channels_redis~=4.1.0 diff --git a/run-qa-checks b/run-qa-checks index 5d442583..5dde1520 100755 --- a/run-qa-checks +++ b/run-qa-checks @@ -14,6 +14,8 @@ cd .. echo '' echo 'Running checks for openwisp-radius' openwisp-qa-check \ + --csslinter \ + --jslinter \ --migration-path "./openwisp_radius/migrations" echo '' diff --git a/runtests.py b/runtests.py index c3d61100..4e689cb0 100755 --- a/runtests.py +++ b/runtests.py @@ -17,4 +17,8 @@ args.insert(3, 'openwisp_radius') else: args.insert(2, 'openwisp2') + if os.environ.get('MONITORING_INTEGRATION', False): + args.extend(['--tag', 'radius_monitoring']) + else: + args.extend(['--exclude-tag', 'radius_monitoring']) execute_from_command_line(args) diff --git a/setup.cfg b/setup.cfg index 17920818..73f56700 100644 --- a/setup.cfg +++ b/setup.cfg @@ -11,5 +11,4 @@ exclude = *.egg-info, .git, ./tests/*settings*.py, docs/* - ./tests/openwisp2/saml/* max-line-length = 88 diff --git a/tests/openwisp2/routing.py b/tests/openwisp2/routing.py new file mode 100644 index 00000000..8f1973a4 --- /dev/null +++ b/tests/openwisp2/routing.py @@ -0,0 +1,12 @@ +from channels.auth import AuthMiddlewareStack +from channels.routing import ProtocolTypeRouter, URLRouter +from channels.security.websocket import AllowedHostsOriginValidator +from openwisp_controller.routing import get_routes + +application = ProtocolTypeRouter( + { + 'websocket': AllowedHostsOriginValidator( + AuthMiddlewareStack(URLRouter(get_routes())) + ) + } +) diff --git a/tests/openwisp2/sample_radius/api/views.py b/tests/openwisp2/sample_radius/api/views.py index 84a56291..b8efb235 100644 --- a/tests/openwisp2/sample_radius/api/views.py +++ b/tests/openwisp2/sample_radius/api/views.py @@ -16,6 +16,7 @@ PasswordResetConfirmView as BasePasswordResetConfirmView, ) from openwisp_radius.api.views import PasswordResetView as BasePasswordResetView +from openwisp_radius.api.views import RadiusAccountingView as BaseRadiusAccountingView from openwisp_radius.api.views import RegisterView as BaseRegisterView from openwisp_radius.api.views import UserAccountingView as BaseUserAccountingView from openwisp_radius.api.views import UserRadiusUsageView as BaseUserRadiusUsageView @@ -93,6 +94,10 @@ class DownloadRadiusBatchPdfView(BaseDownloadRadiusBatchPdfView): pass +class RadiusAccountingView(BaseRadiusAccountingView): + pass + + authorize = AuthorizeView.as_view() postauth = PostAuthView.as_view() accounting = AccountingView.as_view() @@ -110,3 +115,4 @@ class DownloadRadiusBatchPdfView(BaseDownloadRadiusBatchPdfView): validate_phone_token = ValidatePhoneTokenView.as_view() change_phone_number = ChangePhoneNumberView.as_view() download_rad_batch_pdf = DownloadRadiusBatchPdfView.as_view() +radius_accounting = RadiusAccountingView.as_view() diff --git a/tests/openwisp2/settings.py b/tests/openwisp2/settings.py index 1644021c..8f21f081 100644 --- a/tests/openwisp2/settings.py +++ b/tests/openwisp2/settings.py @@ -1,5 +1,6 @@ import os import sys +from datetime import timedelta from celery.schedules import crontab @@ -9,7 +10,7 @@ # Set DEBUG to False in production DEBUG = True - +INTERNAL_IPS = ['127.0.0.1'] SECRET_KEY = '&a@f(0@lrl%606smticbu20=pvribdvubk5=gjti8&n1y%bi&4' ALLOWED_HOSTS = [] @@ -24,19 +25,17 @@ 'django.contrib.messages', 'django.contrib.staticfiles', 'django.contrib.humanize', - # openwisp admin theme - 'openwisp_utils.admin_theme', - 'openwisp_users.accounts', # all-auth 'django.contrib.sites', + # overrides allauth templates + # must precede allauth + 'openwisp_users.accounts', 'allauth', 'allauth.account', 'allauth.socialaccount', - # rest framework - 'rest_framework', - 'django_filters', - # registration - 'rest_framework.authtoken', + 'django_extensions', + # openwisp2 modules + 'openwisp_users', 'dj_rest_auth', 'dj_rest_auth.registration', # social login @@ -44,15 +43,22 @@ 'allauth.socialaccount.providers.google', # openwisp radius 'openwisp_radius', - 'openwisp_users', - # admin + # openwisp2 admin theme + # (must be loaded here) + 'openwisp_utils.admin_theme', 'admin_auto_filters', + # admin 'django.contrib.admin', + 'django.forms', + # other dependencies + 'rest_framework', + 'rest_framework.authtoken', + 'django_filters', 'private_storage', 'drf_yasg', - 'django_extensions', 'openwisp2.integrations', 'djangosaml2', + # 'debug_toolbar', ] LOGIN_REDIRECT_URL = 'admin:index' @@ -83,6 +89,7 @@ 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'djangosaml2.middleware.SamlSessionMiddleware', + # 'debug_toolbar.middleware.DebugToolbarMiddleware', ] SESSION_COOKIE_SECURE = True @@ -305,6 +312,88 @@ OPENWISP_USERS_AUTH_API = True +if os.environ.get('MONITORING_INTEGRATION', False): + INSTALLED_APPS.insert( + INSTALLED_APPS.index('django.contrib.sites'), + 'django.contrib.gis', + ) + INSTALLED_APPS.insert( + INSTALLED_APPS.index('openwisp_radius') + 1, + 'openwisp_radius.integrations.monitoring', + ) + INSTALLED_APPS.insert( + INSTALLED_APPS.index('rest_framework.authtoken'), 'rest_framework_gis' + ) + INSTALLED_APPS.append('channels') + dj_rest_auth_index = INSTALLED_APPS.index('dj_rest_auth') + INSTALLED_APPS = ( + INSTALLED_APPS[:dj_rest_auth_index] + + [ + 'openwisp_controller.pki', + 'openwisp_controller.config', + 'openwisp_controller.geo', + 'openwisp_controller.connection', + 'openwisp_monitoring.monitoring', + 'openwisp_monitoring.device', + 'openwisp_monitoring.check', + 'nested_admin', + 'openwisp_notifications', + 'flat_json_widget', + 'openwisp_ipam', + 'sortedm2m', + 'reversion', + 'leaflet', + 'import_export', + ] + + INSTALLED_APPS[dj_rest_auth_index:] + ) + TEMPLATES[0]['OPTIONS']['context_processors'].append( + 'openwisp_notifications.context_processors.notification_api_settings' + ) + + TIMESERIES_DATABASE = { + 'BACKEND': 'openwisp_monitoring.db.backends.influxdb', + 'USER': 'openwisp', + 'PASSWORD': 'openwisp', + 'NAME': 'openwisp2', + 'HOST': os.getenv('INFLUXDB_HOST', 'localhost'), + 'PORT': '8086', + } + EXTENDED_APPS = ['django_x509', 'django_loci'] + + ASGI_APPLICATION = 'openwisp2.routing.application' + if TESTING: + CHANNEL_LAYERS = { + 'default': {'BACKEND': 'channels.layers.InMemoryChannelLayer'} + } + else: + CHANNEL_LAYERS = { + 'default': { + 'BACKEND': 'channels_redis.core.RedisChannelLayer', + 'CONFIG': {'hosts': [f'redis://{redis_host}/7']}, + } + } + CACHES = { + 'default': { + 'BACKEND': 'django_redis.cache.RedisCache', + 'LOCATION': 'redis://127.0.0.1:6379/6', + 'OPTIONS': { + 'CLIENT_CLASS': 'django_redis.client.DefaultClient', + }, + } + } + DATABASES['default']['ENGINE'] = 'openwisp_utils.db.backends.spatialite' + CELERY_BEAT_SCHEDULE.update( + { + 'write_user_registration_metrics': { + 'task': 'openwisp_radius.integrations.monitoring.tasks.write_user_registration_metrics', + 'schedule': timedelta(hours=1), + 'args': None, + 'relative': True, + } + } + ) + if os.environ.get('SAMPLE_APP', False): INSTALLED_APPS.remove('openwisp_radius') INSTALLED_APPS.remove('openwisp_users') @@ -363,3 +452,5 @@ from .local_settings import * except ImportError: pass + +FORM_RENDERER = 'django.forms.renderers.TemplatesSetting' diff --git a/tests/openwisp2/urls.py b/tests/openwisp2/urls.py index a9465644..17fcb728 100644 --- a/tests/openwisp2/urls.py +++ b/tests/openwisp2/urls.py @@ -46,3 +46,14 @@ ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) urlpatterns += staticfiles_urlpatterns() + +if os.environ.get('MONITORING_INTEGRATION'): + urlpatterns = [ + path('', include('openwisp_controller.urls')), + path('', include('openwisp_monitoring.urls')), + ] + urlpatterns + +if settings.DEBUG and 'debug_toolbar' in settings.INSTALLED_APPS: + import debug_toolbar + + urlpatterns.append(path('__debug__/', include(debug_toolbar.urls))) diff --git a/tests/openwisp2/views.py b/tests/openwisp2/views.py index 8071cd7f..4e7e18dd 100644 --- a/tests/openwisp2/views.py +++ b/tests/openwisp2/views.py @@ -1,4 +1,5 @@ import logging +import random import uuid from urllib.parse import urlparse @@ -81,6 +82,8 @@ def captive_portal_logout(request): terminate_cause='User-Request', nas_ip_address='127.0.0.1', unique_id=session_id, + input_octets=random.randint(20, 50) * 100000, + output_octets=random.randint(20, 50) * 100000, ) post_accounting_data(request, data) logger.info(