diff --git a/.coveragerc b/.coveragerc index ac42c385..f841e0c5 100644 --- a/.coveragerc +++ b/.coveragerc @@ -5,3 +5,6 @@ omit = /*/__init__.py /setup.py /*/migrations/* +source = openwisp_network_topology +parallel = true +concurrency = multiprocessing diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 532fb95e..e61c05f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ on: jobs: build: name: Python==${{ matrix.python-version }} | ${{ matrix.django-version }} - runs-on: ubuntu-22.04 + runs-on: ubuntu-20.04 strategy: fail-fast: false @@ -26,6 +26,18 @@ jobs: - django~=4.0.0 steps: + - name: Install system packages + run: | + sudo apt update && + sudo apt -qq install \ + sqlite3 \ + gdal-bin \ + libproj-dev \ + libgeos-dev \ + libspatialite-dev \ + spatialite-bin \ + libsqlite3-mod-spatialite + - uses: actions/checkout@v2 with: ref: ${{ github.event.pull_request.head.sha }} @@ -45,6 +57,9 @@ jobs: - name: Install python system packages run: pip install -U pip wheel setuptools + - name: Start InfluxDB and Redis container + run: docker-compose up -d influxdb redis + - name: Install test dependencies run: | pip install -U -r requirements-test.txt @@ -59,7 +74,9 @@ jobs: - name: Tests run: | - coverage run --source=openwisp_network_topology runtests.py + coverage run runtests.py --parallel + WIFI_MESH=1 coverage run runtests.py + coverage combine SAMPLE_APP=1 ./runtests.py --parallel --keepdb - name: Upload Coverage diff --git a/.gitignore b/.gitignore index d83c994a..dc7eac7d 100644 --- a/.gitignore +++ b/.gitignore @@ -35,7 +35,7 @@ pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ -.coverage +.coverage* .cache nosetests.xml coverage.xml diff --git a/README.rst b/README.rst index 6ae1c844..dfe45ba5 100644 --- a/README.rst +++ b/README.rst @@ -166,7 +166,11 @@ Install sqlite: .. code-block:: shell - sudo apt-get install sqlite3 libsqlite3-dev + sudo apt install -y sqlite3 libsqlite3-dev + # Install system dependencies for spatialite which is required + # to run tests for openwisp-network-topology integrations with + # openwisp-controller and openwisp-monitoring. + sudo apt install libspatialite-dev libsqlite3-mod-spatialite Install your forked repo: @@ -176,6 +180,14 @@ Install your forked repo: cd openwisp-network-topology/ python setup.py develop +Start InfluxDB and Redis using Docker +(required by the test project to run tests for +`WiFi Mesh Integration <#openwisp_network_topology_wifi_mesh_integration>`_): + +.. code-block:: shell + + docker-compose up -d influxdb redis + Install test requirements: .. code-block:: shell @@ -196,7 +208,14 @@ Run tests with: .. code-block:: shell + # Running tests without setting the "WIFI_MESH" environment + # variable will not run tests for WiFi Mesh integration. + # This is done to avoid slowing down the test suite by adding + # dependencies which are only used by the integration. ./runtests.py + # You can run the tests only for WiFi mesh integration using + # the following command + WIFI_MESH=1 ./runtests.py Run qa tests: @@ -446,6 +465,12 @@ This additional and optional module provides the following features: - the management IP address of the related device is updated straightaway - if OpenWISP Monitoring is enabled, the device checks are triggered (e.g.: ping) +- if `OpenWISP Monitoring `_ + is installed and enabled, the system can automatically create topology + for the WiFi Mesh (802.11s) interfaces using the monitoring data provided by the agent. + You can enable this by setting `OPENWISP_NETWORK_TOPOLOGY_WIFI_MESH_INTEGRATION + <#openwisp_network_topology_wifi_mesh_integration>`_ to ``True``. + This integration makes the whole system a lot faster in detecting important events in the network. In order to use this module simply add @@ -464,6 +489,31 @@ In order to use this module simply add 'rest_framework', ] +If you have enabled WiFI Mesh integration, you will also need to update the +``CELERY_BEAT_SCHEDULE`` as follow: + +.. code-block:: python + + CELERY_BEAT_SCHEDULE = { + 'create_mesh_topology': { + # This task generates the mesh topology from monitoring data + 'task': 'openwisp_network_topology.integrations.device.tasks.create_mesh_topology', + # Execute this task every 5 minutes + 'schedule': timedelta(minutes=5), + 'args': ( + # List of organization UUIDs. The mesh topology will be + # created only for devices belonging these organizations. + [ + '4e002f97-eb01-4371-a4a8-857faa22fe5c', + 'be88d4c4-599a-4ca2-a1c0-3839b4fdc315' + ], + # The task won't use monitoring data reported + # before this time (in seconds) + 6 * 60 # 6 minutes + ), + }, + } + If you are enabling this integration on a pre-existing system, use the `create_device_nodes <#create-device-nodes>`_ management command to create the relationship between devices and nodes. @@ -586,6 +636,25 @@ When enabled, the API `endpoints <#list-of-endpoints>`_ will only allow authenti who have the necessary permissions to access the objects which belong to the organizations the user manages. +``OPENWISP_NETWORK_TOPOLOGY_WIFI_MESH_INTEGRATION`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + ++--------------+---------------+ +| **type**: | ``boolean`` | ++--------------+---------------+ +| **default**: | ``False`` | ++--------------+---------------+ + +When enabled, network topology objects will be automatically created and +updated based on the WiFi mesh interfaces peer information supplied +by the monitoring agent. + +**Note:** The network topology objects are created using the device monitoring data +collected by OpenWISP Monitoring. Thus, it requires +`integration with OpenWISP Controller and OpenWISP Monitoring +<#integration-with-openwisp-controller-and-openwisp-monitoring>`_ to be enabled +in the Django project. + Rest API -------- @@ -1070,6 +1139,7 @@ Once you have created the models, add the following to your ``settings.py``: TOPOLOGY_TOPOLOGY_MODEL = 'sample_network_topology.Topology' # if you use the integration with OpenWISP Controller and/or OpenWISP Monitoring TOPOLOGY_DEVICE_DEVICENODE_MODEL = 'sample_integration_device.DeviceNode' + TOPOLOGY_DEVICE_WIFIMESH_MODEL = 'sample_integration_device.WifiMesh' Substitute ``sample_network_topology`` with the name you chose in step 1. 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/openwisp_network_topology/integrations/device/admin.py b/openwisp_network_topology/integrations/device/admin.py new file mode 100644 index 00000000..23eb37d1 --- /dev/null +++ b/openwisp_network_topology/integrations/device/admin.py @@ -0,0 +1,19 @@ +from django.contrib import admin +from swapper import load_model + +from openwisp_network_topology.admin import TopologyAdmin + +from . import settings as app_settings + +WifiMesh = load_model('topology_device', 'WifiMesh') +Topology = load_model('topology', 'Topology') + + +class WifiMeshInlineAdmin(admin.StackedInline): + model = WifiMesh + extra = 0 + max_num = 1 + + +if app_settings.WIFI_MESH_INTEGRATION: + TopologyAdmin.inlines = TopologyAdmin.inlines + [WifiMeshInlineAdmin] diff --git a/openwisp_network_topology/integrations/device/base/models.py b/openwisp_network_topology/integrations/device/base/models.py index e60562cb..c3953723 100644 --- a/openwisp_network_topology/integrations/device/base/models.py +++ b/openwisp_network_topology/integrations/device/base/models.py @@ -2,12 +2,17 @@ from ipaddress import ip_address, ip_network from django.conf import settings +from django.core.exceptions import ImproperlyConfigured from django.db import models from django.utils.module_loading import import_string +from django.utils.timezone import datetime, now, timedelta +from django.utils.translation import gettext_lazy as _ from swapper import get_model_name, load_model from openwisp_utils.base import UUIDModel +from .. import settings as app_settings + logger = logging.getLogger(__name__) trigger_device_checks_path = 'openwisp_monitoring.device.tasks.trigger_device_checks' @@ -31,6 +36,9 @@ class AbstractDeviceNode(UUIDModel): 'netdiff.WireguardParser': { 'auto_create': 'auto_create_wireguard', }, + 'netdiff.NetJsonParser': { + 'auto_create': 'auto_create_netjsongraph', + }, } class Meta: @@ -49,7 +57,7 @@ def save_device_node(cls, device, node): node.organization_id = device.organization_id node.save(update_fields=['organization_id']) except Exception: - logger.exception('Exception raised during auto_create_openvpn') + logger.exception('Exception raised during save_device_node') return else: logger.info(f'DeviceNode relation created for {node.label} - {device.name}') @@ -126,6 +134,31 @@ def auto_create_wireguard(cls, node): return return cls.save_device_node(device, node) + @classmethod + def auto_create_netjsongraph(cls, node): + if len(node.addresses) < 2: + # The first MAC address is Device.mac_address and + # the second one is interface's MAC address. + # If a node only has one MAC address, it means that + # it only contains interface MAC address. + return + Device = load_model('config', 'Device') + device_filter = models.Q( + mac_address__iexact=node.addresses[0].rpartition('@')[0] + ) + if node.organization_id: + device_filter &= models.Q(organization_id=node.organization_id) + device = ( + Device.objects.only( + 'id', 'name', 'last_ip', 'management_ip', 'organization_id' + ) + .filter(device_filter) + .first() + ) + if not device: + return + return cls.save_device_node(device, node) + def link_action(self, link, status): """ Performs clean-up operations when link goes down. @@ -185,3 +218,314 @@ def trigger_device_updates(cls, link): if 'openwisp_monitoring.device' in settings.INSTALLED_APPS: run_checks = import_string(trigger_device_checks_path) run_checks.delay(device_node.device.pk, recovery=link.status == 'up') + + +class AbstractWifiMesh(UUIDModel): + _NODE_PROPERTIES = [ + 'ht', + 'vht', + 'he', + 'mfp', + 'wmm', + 'vendor', + ] + _LINK_PROPERTIES = [ + 'auth', + 'authorized', + 'noise', + 'signal', + 'signal_avg', + 'mesh_llid', + 'mesh_plid', + 'mesh_plink', + 'mesh_non_peer_ps', + ] + + topology = models.ForeignKey( + get_model_name('topology', 'Topology'), on_delete=models.CASCADE + ) + mesh_id = models.CharField( + max_length=32, null=False, blank=False, verbose_name=_('Mesh ID') + ) + + class Meta: + abstract = True + + @classmethod + def create_topology(cls, organization_ids, discard_older_data_time): + if not app_settings.WIFI_MESH_INTEGRATION: + raise ImproperlyConfigured( + '"OPENIWSP_NETWORK_TOPOLOGY_WIFI_MESH_INTEGRATION" is set to "False".' + ) + Link = load_model('topology', 'Link') + for org_id in organization_ids: + intermediate_topologies = cls._create_intermediate_topologies( + org_id, discard_older_data_time + ) + if not intermediate_topologies: + Link.objects.filter( + topology__wifimesh__isnull=False, organization_id=org_id + ).exclude(topology__wifimesh__in=intermediate_topologies.keys()).update( + status='down' + ) + continue + cls._create_topology(intermediate_topologies, org_id) + + @classmethod + def _create_intermediate_topologies(cls, organization_id, discard_older_data_time): + """ + Creates an intermediate data structure for creating topologies. + The intermediate topology contains intermediate data structure + for nodes and links. + + Every device in the mesh sends monitoring data. The data contains + information of the clients the device is connected to. + Using the information sent by individual device, a hub-spoke + topology is created between device (hub) and clients (spoke). + These individual topologies are then complied to create the + complete mesh topology. + """ + DeviceData = load_model('device_monitoring', 'DeviceData') + intermediate_topologies = {} + query = DeviceData.objects.filter(organization_id=organization_id).only( + 'mac_address' + ) + discard_older_data_time = now() - timedelta(seconds=discard_older_data_time) + for device_data in query.iterator(): + try: + assert device_data.data is not None + data_timestamp = datetime.fromisoformat(device_data.data_timestamp) + assert data_timestamp > discard_older_data_time + except (AttributeError, AssertionError): + continue + for interface in device_data.data.get('interfaces', []): + if not AbstractWifiMesh._is_mesh_interfaces(interface): + continue + mesh_id = '{}@{}'.format( + interface['wireless']['ssid'], interface['wireless']['channel'] + ) + if mesh_id not in intermediate_topologies: + intermediate_topologies[mesh_id] = { + 'nodes': {}, + 'links': {}, + 'mac_mapping': {}, + } + topology = intermediate_topologies[mesh_id] + ( + collected_nodes, + collected_links, + ) = cls._get_intermediate_nodes_and_links( + interface, device_data, topology['mac_mapping'] + ) + AbstractWifiMesh._merge_nodes( + interface, topology['nodes'], collected_nodes + ) + AbstractWifiMesh._merge_links( + interface, topology['links'], collected_links + ) + return intermediate_topologies + + @classmethod + def _get_intermediate_nodes_and_links(cls, interface, device_data, mac_mapping): + """ + Create intermediate data structures for nodes and links. + These intermediate data structures are required because the + interface's MAC address can be different from the device's main + MAC address. Thus, these data structures are aimed to provide + quick lookup while mapping interface MAC address to the + device MAC address. + """ + device_mac = device_data.mac_address.upper() + interface_mac = interface['mac'].upper() + channel = interface['wireless']['channel'] + device_node_id = f'{device_mac}@{channel}' + mac_mapping[interface_mac] = device_node_id + collected_nodes = {} + collected_links = {} + for client in interface['wireless'].get('clients', []): + client_mac = client['mac'].upper() + node_properties = {} + for property in cls._NODE_PROPERTIES: + if property in client: + node_properties[property] = client[property] + collected_nodes[client_mac] = {'properties': node_properties} + if client.get('mesh_plink') and client.get('mesh_plink') != 'ESTAB': + # The link is not established. + # Do not add this link to the topology. + continue + link_properties = {} + for property in cls._LINK_PROPERTIES: + if property in client: + link_properties[property] = client[property] + + collected_links[client_mac] = { + interface_mac: { + 'source': f'{device_mac}@{channel}', + 'target': f'{client_mac}', + 'cost': 1.0, + 'properties': link_properties, + } + } + return collected_nodes, collected_links + + @staticmethod + def _is_mesh_interfaces(interface): + return interface.get('wireless', False) and interface['wireless'].get( + 'mode' + ) in ['802.11s'] + + @staticmethod + def _merge_nodes(interface, topology_nodes, collected_nodes): + interface_mac = interface['mac'].upper() + topology_nodes.update(collected_nodes) + if not topology_nodes.get(interface_mac): + # Handle case when there is only one node present + # in the mesh + topology_nodes[interface_mac] = {} + + @staticmethod + def _merge_links(interface, topology_links, collected_links): + """ + Merges properties of links from the topology stored + in the database (topology_links) with link collected from + the interface's wireless client data (collected_links). + + It takes into consideration the nature of the property + (e.g. taking average for signal and noise, etc.). + """ + interface_mac = interface['mac'].upper() + if topology_links.get(interface_mac): + for source, topology_link in topology_links[interface_mac].items(): + if not collected_links.get(source): + continue + for property, value in collected_links[source][interface_mac][ + 'properties' + ].items(): + if isinstance(value, int): + # If the value is integer, then take average of the + # values provided by the two nodes. + # This is for fields like signal and noise. + if topology_link['properties'].get(property): + topology_link['properties'][property] = ( + value + topology_link['properties'][property] + ) // 2 + else: + # The link in topology does not contain this property, + # hence instead of averaging, assign the current value. + topology_link['properties'][property] = value + elif ( + property in ['mesh_plink', 'mesh_non_peer_ps'] + and topology_link['properties'].get(property) + and value != topology_link['properties'][property] + ): + # The value for "mesh_plink" and "mesh_non_peer_ps" properties + # should be reported same by both the nodes. + # Flag the value as inconsistent if they are different. + topology_link['properties'][ + property + ] = 'INCONSISTENT: ({} / {})'.format( + value, + topology_link['properties'][property], + ) + collected_links.pop(source) + for key, value in collected_links.items(): + try: + topology_links[key].update(value) + except KeyError: + topology_links[key] = value + + @staticmethod + def _create_topology(intermediate_topologies, organization_id): + for mesh_id, intermediate in intermediate_topologies.items(): + topology_obj = AbstractWifiMesh._get_mesh_topology(mesh_id, organization_id) + nodes = AbstractWifiMesh._get_nodes_from_intermediate_topology(intermediate) + links = AbstractWifiMesh._get_links_from_intermediate_topology(intermediate) + topology = { + 'type': 'NetworkGraph', + 'protocol': 'Mesh', + 'version': '1', + 'metric': 'Airtime', + 'nodes': nodes, + 'links': links, + } + topology_obj.receive(topology) + + @staticmethod + def _get_nodes_from_intermediate_topology(intermediate_topology): + """ + Using the "intermediate_topology", return all the + nodes of the topology. + + It maps the interface's MAC address to the device's main MAC address + which is used to create DeviceNode relation. + """ + nodes = [] + mac_mapping = intermediate_topology['mac_mapping'] + for interface_mac, intermediate_node in intermediate_topology['nodes'].items(): + device_mac = mac_mapping.get(interface_mac) + if not device_mac: + continue + node = { + 'id': device_mac, + 'label': device_mac, + 'local_addresses': [interface_mac], + } + if intermediate_node.get('properties'): + node['properties'] = intermediate_node['properties'] + nodes.append(node) + return nodes + + @staticmethod + def _get_links_from_intermediate_topology(intermediate_topology): + """ + Using the "intermediate_topology", return all the + links of the topology. + + It maps the interface's MAC address to the device's main MAC address. + """ + links = [] + mac_mapping = intermediate_topology['mac_mapping'] + for interface_mac, intermediate_link in intermediate_topology['links'].items(): + device_mac = mac_mapping.get(interface_mac) + if not device_mac: + continue + for link in intermediate_link.values(): + link['target'] = device_mac + links.append(link) + return links + + @staticmethod + def _get_mesh_topology(mesh_id, organization_id): + """ + Get or create topology for the given mesh_id and organization_id. + It also creates WifiMesh object to keep track of mesh's ID + if a new topology object is created. + """ + Topology = load_model('topology', 'Topology') + WifiMesh = load_model('topology_device', 'WifiMesh') + try: + mesh_topology = ( + WifiMesh.objects.select_related('topology') + .get( + mesh_id=mesh_id, + topology__organization_id=organization_id, + ) + .topology + ) + except WifiMesh.DoesNotExist: + mesh_topology = Topology( + organization_id=organization_id, + label=mesh_id, + parser='netdiff.NetJsonParser', + strategy='receive', + expiration_time=330, + ) + mesh_topology.full_clean() + mesh_topology.save() + wifi_mesh = WifiMesh( + mesh_id=mesh_id, + topology=mesh_topology, + ) + wifi_mesh.full_clean() + wifi_mesh.save() + return mesh_topology diff --git a/openwisp_network_topology/integrations/device/migrations/0002_wifimesh.py b/openwisp_network_topology/integrations/device/migrations/0002_wifimesh.py new file mode 100644 index 00000000..9b7a52e3 --- /dev/null +++ b/openwisp_network_topology/integrations/device/migrations/0002_wifimesh.py @@ -0,0 +1,44 @@ +# Generated by Django 3.2.18 on 2023-04-17 18:06 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.TOPOLOGY_TOPOLOGY_MODEL), + ('topology_device', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='WifiMesh', + fields=[ + ( + 'id', + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ('mesh_id', models.CharField(max_length=32, verbose_name='Mesh ID')), + ( + 'topology', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.TOPOLOGY_TOPOLOGY_MODEL, + ), + ), + ], + options={ + 'abstract': False, + 'swappable': 'TOPOLOGY_DEVICE_WIFIMESH_MODEL', + }, + ), + ] diff --git a/openwisp_network_topology/integrations/device/migrations/0003_wifimesh_permissions.py b/openwisp_network_topology/integrations/device/migrations/0003_wifimesh_permissions.py new file mode 100644 index 00000000..8d425713 --- /dev/null +++ b/openwisp_network_topology/integrations/device/migrations/0003_wifimesh_permissions.py @@ -0,0 +1,48 @@ +from django.conf import settings +from django.contrib.auth.models import Permission +from django.db import migrations + +from openwisp_network_topology.migrations import create_default_permissions + + +def assign_permissions_to_groups(apps, schema_editor): + create_default_permissions(apps, schema_editor) + + def _add_permission_to_group(group, models, operations): + for model in models: + for operation in operations: + try: + permission = Permission.objects.get( + codename='{}_{}'.format(operation, model) + ) + except Permission.DoesNotExist: + continue + else: + group.permissions.add(permission.pk) + + Group = apps.get_model('openwisp_users', 'Group') + manage_operations = ['add', 'change', 'delete', 'view'] + try: + admin = Group.objects.get(name='Administrator') + operator = Group.objects.get(name='Operator') + # consider failures custom cases + # that do not have to be dealt with + except Group.DoesNotExist: + return + + _add_permission_to_group(operator, ['wifimesh'], manage_operations) + _add_permission_to_group(admin, ['wifimesh'], manage_operations) + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.TOPOLOGY_TOPOLOGY_MODEL), + ('topology_device', '0002_wifimesh'), + ] + + operations = [ + migrations.RunPython( + assign_permissions_to_groups, reverse_code=migrations.RunPython.noop + ) + ] diff --git a/openwisp_network_topology/integrations/device/models.py b/openwisp_network_topology/integrations/device/models.py index 31ca2392..f3835b02 100644 --- a/openwisp_network_topology/integrations/device/models.py +++ b/openwisp_network_topology/integrations/device/models.py @@ -1,9 +1,15 @@ from swapper import swappable_setting -from .base.models import AbstractDeviceNode +from .base.models import AbstractDeviceNode, AbstractWifiMesh class DeviceNode(AbstractDeviceNode): class Meta(AbstractDeviceNode.Meta): abstract = False swappable = swappable_setting('topology_device', 'DeviceNode') + + +class WifiMesh(AbstractWifiMesh): + class Meta(AbstractWifiMesh.Meta): + abstract = False + swappable = swappable_setting('topology_device', 'WifiMesh') diff --git a/openwisp_network_topology/integrations/device/settings.py b/openwisp_network_topology/integrations/device/settings.py new file mode 100644 index 00000000..abb7bf32 --- /dev/null +++ b/openwisp_network_topology/integrations/device/settings.py @@ -0,0 +1,3 @@ +from ...settings import get_settings_value + +WIFI_MESH_INTEGRATION = get_settings_value('WIFI_MESH_INTEGRATION', False) diff --git a/openwisp_network_topology/integrations/device/tasks.py b/openwisp_network_topology/integrations/device/tasks.py index 4f94979a..629d2f6e 100644 --- a/openwisp_network_topology/integrations/device/tasks.py +++ b/openwisp_network_topology/integrations/device/tasks.py @@ -1,6 +1,8 @@ import swapper from celery import shared_task +from . import settings as app_settings + @shared_task def create_device_node_relation(node_pk): @@ -16,3 +18,11 @@ def trigger_device_updates(link_pk): DeviceNode = swapper.load_model('topology_device', 'DeviceNode') link = Link.objects.select_related('topology').get(pk=link_pk) DeviceNode.trigger_device_updates(link) + + +@shared_task +def create_mesh_topology(organization_ids, discard_older_data_time=360): + if not app_settings.WIFI_MESH_INTEGRATION: + return + WifiMesh = swapper.load_model('topology_device', 'WifiMesh') + WifiMesh.create_topology(organization_ids, discard_older_data_time) diff --git a/openwisp_network_topology/integrations/device/tests/__init__.py b/openwisp_network_topology/integrations/device/tests/__init__.py new file mode 100644 index 00000000..cf912d1e --- /dev/null +++ b/openwisp_network_topology/integrations/device/tests/__init__.py @@ -0,0 +1,255 @@ +SIMPLE_MESH_DATA = { + '64:70:02:c3:03:b2': [ + { + 'mac': '64:70:02:c3:03:b3', + 'mtu': 1500, + 'multicast': True, + 'name': 'mesh0', + 'txqueuelen': 1000, + 'type': 'wireless', + 'up': True, + 'wireless': { + 'channel': 11, + 'clients': [ + { + 'auth': True, + 'authorized': True, + 'ht': True, + 'mac': 'a4:bc:3f:ae:c7:0c', + 'mfp': False, + 'noise': -90, + 'vendor': 'TP-LINK TECHNOLOGIES CO.,LTD.', + 'vht': False, + 'wmm': True, + 'mesh_llid': 19000, + 'mesh_plid': 24000, + 'mesh_non_peer_ps': 'ACTIVE', + }, + { + 'auth': True, + 'authorized': True, + 'ht': True, + 'mac': '2a:9a:fb:12:11:77', + 'mfp': False, + 'noise': -94, + 'signal': -58, + 'vendor': 'TP-LINK TECHNOLOGIES CO.,LTD.', + 'vht': False, + 'wmm': True, + 'mesh_llid': 19500, + 'mesh_plid': 24500, + 'mesh_non_peer_ps': 'ACTIVE', + }, + ], + 'country': 'ES', + 'frequency': 2462, + 'htmode': 'HT20', + 'mode': '802.11s', + 'noise': -95, + 'signal': -61, + 'ssid': 'Test Mesh', + 'tx_power': 17, + }, + } + ], + 'a4:bc:3f:ae:c7:0b': [ + { + 'mac': 'a4:bc:3f:ae:c7:0c', + 'mtu': 1500, + 'multicast': True, + 'name': 'mesh0', + 'txqueuelen': 1000, + 'type': 'wireless', + 'up': True, + 'wireless': { + 'channel': 11, + 'clients': [ + { + 'auth': True, + 'authorized': True, + 'ht': True, + 'mac': '64:70:02:C3:03:B3', + 'mfp': False, + 'noise': -98, + 'signal': -58, + 'vendor': 'TP-LINK TECHNOLOGIES CO.,LTD.', + 'vht': False, + 'wmm': True, + 'mesh_llid': 20000, + 'mesh_plid': 25000, + 'mesh_non_peer_ps': 'ACTIVE', + }, + { + 'auth': True, + 'authorized': True, + 'ht': True, + 'mac': '2a:9a:fb:12:11:77', + 'mfp': False, + 'noise': -96, + 'signal': -60, + 'vendor': 'TP-LINK TECHNOLOGIES CO.,LTD.', + 'vht': False, + 'wmm': True, + 'mesh_llid': 19500, + 'mesh_plid': 24500, + 'mesh_non_peer_ps': 'ACTIVE', + }, + ], + 'country': 'ES', + 'frequency': 2462, + 'htmode': 'HT20', + 'mode': '802.11s', + 'noise': -95, + 'signal': -56, + 'ssid': 'Test Mesh', + 'tx_power': 17, + }, + } + ], + '2a:9a:fb:12:11:76': [ + { + 'mac': '2a:9a:fb:12:11:77', + 'mtu': 1500, + 'multicast': True, + 'name': 'mesh0', + 'txqueuelen': 1000, + 'type': 'wireless', + 'up': True, + 'wireless': { + 'channel': 11, + 'clients': [ + { + # Link properties + 'auth': True, + 'authorized': True, + 'noise': -92, + 'signal': -56, + # Node properties + 'ht': True, + 'mfp': False, + 'vht': False, + 'wmm': True, + 'mac': 'a4:bc:3f:ae:c7:0c', + 'vendor': 'TP-LINK TECHNOLOGIES CO.,LTD.', + 'mesh_non_peer_ps': 'LISTEN', + }, + { + 'mac': '0a:cc:ae:34:ff:3d', + 'wps': False, + 'wds': False, + 'ht': True, + 'preauth': False, + 'assoc': True, + 'authorized': True, + 'vht': False, + 'wmm': False, + 'aid': 1, + 'mfp': False, + 'auth': True, + 'vendor': 'TP-LINK TECHNOLOGIES CO.,LTD.', + 'signature': 'test_signature', + 'mesh_plink': 'LISTEN', + }, + ], + 'country': 'ES', + 'frequency': 2462, + 'htmode': 'HT20', + 'mode': '802.11s', + 'noise': -95, + 'signal': -64, + 'ssid': 'Test Mesh', + 'tx_power': 17, + }, + }, + { + 'addresses': [ + { + 'address': '192.168.1.1', + 'family': 'ipv4', + 'mask': 24, + 'proto': 'static', + }, + ], + 'bridge_members': ['eth0.1', 'mesh0'], + 'mac': '2a:9a:fb:12:11:76', + 'mtu': 1500, + 'multicast': True, + 'name': 'br-lan', + 'speed': '-1F', + 'stp': False, + 'txqueuelen': 1000, + 'type': 'bridge', + 'up': True, + }, + { + 'name': 'wlan0', + 'type': 'wireless', + 'up': True, + 'mac': '2a:9a:fb:12:11:78', + 'txqueuelen': 1000, + 'multicast': True, + 'mtu': 1500, + 'wireless': { + 'frequency': 2437, + 'mode': 'access_point', + 'signal': -29, + 'tx_power': 6, + 'channel': 6, + 'ssid': 'testnet', + 'noise': -95, + 'country': 'US', + 'htmode': 'HT20', + 'clients': [ + { + 'mac': '00:ee:ad:34:f5:3b', + 'wps': False, + 'wds': False, + 'ht': True, + 'preauth': False, + 'assoc': True, + 'authorized': True, + 'vht': False, + 'wmm': True, + 'aid': 1, + 'mfp': False, + 'auth': True, + 'vendor': 'TP-LINK TECHNOLOGIES CO.,LTD.', + 'signature': 'test_signature', + }, + ], + }, + }, + ], +} + +SINGLE_NODE_MESH_DATA = { + '64:70:02:c3:03:b2': [ + { + 'mac': '64:70:02:c3:03:b3', + 'mtu': 1500, + 'multicast': True, + 'name': 'mesh0', + 'txqueuelen': 1000, + 'type': 'wireless', + 'up': True, + 'wireless': { + 'channel': 11, + 'clients': [], + 'country': 'ES', + 'frequency': 2462, + 'htmode': 'HT20', + 'mode': '802.11s', + 'noise': -95, + 'signal': -61, + 'ssid': 'Test Mesh', + 'tx_power': 17, + 'mesh_llid': 19751, + 'mesh_local_ps': 'ACTIVE', + 'mesh_non_peer_ps': 'ACTIVE', + 'mesh_peer_ps': 'ACTIVE', + 'mesh_plid': 24413, + 'mesh_plink': 'ESTAB', + }, + } + ], +} diff --git a/openwisp_network_topology/integrations/device/tests.py b/openwisp_network_topology/integrations/device/tests/test_integration.py similarity index 96% rename from openwisp_network_topology/integrations/device/tests.py rename to openwisp_network_topology/integrations/device/tests/test_integration.py index 953313ff..4caced97 100644 --- a/openwisp_network_topology/integrations/device/tests.py +++ b/openwisp_network_topology/integrations/device/tests/test_integration.py @@ -20,13 +20,14 @@ from openwisp_utils.admin_theme.dashboard import DASHBOARD_CHARTS, DASHBOARD_TEMPLATES from openwisp_utils.admin_theme.menu import MENU -from .base.models import logger as models_logger -from .base.models import trigger_device_checks_path +from ..base.models import logger as models_logger +from ..base.models import trigger_device_checks_path Node = swapper.load_model('topology', 'Node') Link = swapper.load_model('topology', 'Link') Topology = swapper.load_model('topology', 'Topology') DeviceNode = swapper.load_model('topology_device', 'DeviceNode') +WifiMesh = swapper.load_model('topology_device', 'WifiMesh') Device = swapper.load_model('config', 'Device') Template = swapper.load_model('config', 'Template') Vpn = swapper.load_model('config', 'Template') @@ -515,3 +516,19 @@ def test_link_update_status(self): self.assertEqual(response.status_code, 200) link.refresh_from_db() self.assertEqual(link.status, 'down') + + def test_topology_admin(self): + """ + Tests WifiMeshInlineAdmin is absent in TopologyAdmin + when OPENWISP_NETWORK_TOPOLOGY_WIFI_MESH_INTEGRATION + is set to False. + + Note: This test is present here because TopologyAdmin class + cannot be patched based on app_settings.WIFI_MESH_INTEGRATION + once the project is initialized. + """ + topology = Topology.objects.first() + response = self.client.get( + reverse(f'{self.prefix}_topology_change', args=[topology.id]) + ) + self.assertNotContains(response, 'Wifi mesh') diff --git a/openwisp_network_topology/integrations/device/tests/test_wifi_mesh.py b/openwisp_network_topology/integrations/device/tests/test_wifi_mesh.py new file mode 100644 index 00000000..5b0c289f --- /dev/null +++ b/openwisp_network_topology/integrations/device/tests/test_wifi_mesh.py @@ -0,0 +1,273 @@ +import json +from copy import deepcopy +from unittest.mock import patch + +import swapper +from django.core.cache import cache +from django.core.exceptions import ImproperlyConfigured +from django.test import TransactionTestCase, tag +from django.urls import reverse +from django.utils.timezone import now, timedelta +from freezegun import freeze_time + +from .. import settings as app_settings +from ..tasks import create_mesh_topology +from . import SIMPLE_MESH_DATA, SINGLE_NODE_MESH_DATA +from .utils import TopologyTestMixin + +Node = swapper.load_model('topology', 'Node') +Link = swapper.load_model('topology', 'Link') +Topology = swapper.load_model('topology', 'Topology') +DeviceNode = swapper.load_model('topology_device', 'DeviceNode') +WifiMesh = swapper.load_model('topology_device', 'WifiMesh') +Device = swapper.load_model('config', 'Device') + + +@tag('wifi_mesh') +class TestWifiMeshIntegration(TopologyTestMixin, TransactionTestCase): + app_label = 'topology' + + def setUp(self): + super().setUp() + cache.clear() + + @property + def prefix(self): + return 'admin:{0}'.format(self.app_label) + + def _populate_mesh(self, data): + org = self._get_org() + devices = [] + for mac, interfaces in data.items(): + device = self._create_device(name=mac, mac_address=mac, organization=org) + devices.append(device) + response = self.client.post( + '{0}?key={1}&time={2}'.format( + reverse('monitoring:api_device_metric', args=[device.id]), + device.key, + now().utcnow().strftime('%d-%m-%Y_%H:%M:%S.%f'), + ), + data=json.dumps( + { + 'type': 'DeviceMonitoring', + 'interfaces': interfaces, + } + ), + content_type='application/json', + ) + self.assertEqual(response.status_code, 200) + create_mesh_topology.delay(organization_ids=(org.id,)) + return devices, org + + @patch.object(app_settings, 'WIFI_MESH_INTEGRATION', False) + def test_wifi_mesh_integration_disabled(self): + with self.subTest('Test calling "create_mesh_topology" task'): + with patch.object(WifiMesh, 'create_topology') as mocked: + _, org = self._populate_mesh(SIMPLE_MESH_DATA) + self.assertEqual(Topology.objects.count(), 0) + mocked.assert_not_called() + + # Ensure the following sub-test does not fail if the + # previous one fails. + Topology.objects.all().delete() + + with self.subTest('Test calling WifiMesh.create_topology'): + with self.assertRaises(ImproperlyConfigured) as error: + WifiMesh.create_topology( + organization_ids=[org.id], discard_older_data_time=360 + ) + self.assertEqual( + str(error.exception), + '"OPENIWSP_NETWORK_TOPOLOGY_WIFI_MESH_INTEGRATION" is set to "False".', + ) + + def test_simple_mesh(self): + devices, org = self._populate_mesh(SIMPLE_MESH_DATA) + self.assertEqual(Topology.objects.filter(organization=org).count(), 1) + topology = Topology.objects.filter(organization=org).first() + self.assertEqual( + WifiMesh.objects.filter(topology=topology, mesh_id='Test Mesh@11').count(), + 1, + ) + self.assertEqual( + Node.objects.filter( + topology=topology, + organization=org, + properties__contains=( + '{\n "ht": true,\n "vht": null,\n "mfp": false,\n' + ' "wmm": true,\n "vendor": "TP-LINK TECHNOLOGIES CO.,LTD."\n}' + ), + ).count(), + 3, + ) + self.assertEqual( + Link.objects.filter( + topology=topology, + organization=org, + properties__contains='"noise": -94', + ) + .filter(properties__contains='"signal": -58') + .filter(properties__contains='"mesh_llid": 19500') + .filter(properties__contains='"mesh_plid": 24500') + .count(), + 3, + ) + self.assertEqual( + Link.objects.filter( + topology=topology, + organization=org, + properties__contains='"mesh_non_peer_ps": "INCONSISTENT: (LISTEN / ACTIVE)"', + ).count(), + 1, + ) + self.assertEqual(DeviceNode.objects.filter(device__in=devices).count(), 3) + + # Test DeviceNode creation logic is not executed when the create_topology + # is executed again + with patch.object(DeviceNode, 'auto_create') as mocked_auto_create: + create_mesh_topology.delay(organization_ids=(org.id,)) + mocked_auto_create.assert_not_called() + + @patch('logging.Logger.exception') + def test_single_node_mesh(self, mocked_logger): + devices, org = self._populate_mesh(SINGLE_NODE_MESH_DATA) + self.assertEqual(Topology.objects.filter(organization=org).count(), 1) + topology = Topology.objects.filter(organization=org).first() + self.assertEqual( + WifiMesh.objects.filter(topology=topology, mesh_id='Test Mesh@11').count(), + 1, + ) + self.assertEqual( + Node.objects.filter( + topology=topology, + organization=org, + ).count(), + 1, + ) + self.assertEqual( + Link.objects.filter( + topology=topology, + organization=org, + ).count(), + 0, + ) + self.assertEqual(DeviceNode.objects.filter(device__in=devices).count(), 1) + mocked_logger.assert_not_called() + + def test_mesh_id_changed(self): + devices, org = self._populate_mesh(SIMPLE_MESH_DATA) + self.assertEqual(Topology.objects.filter(organization=org).count(), 1) + self.assertEqual(WifiMesh.objects.filter(mesh_id='Test Mesh@11').count(), 1) + topology = Topology.objects.filter(organization=org).first() + self.assertEqual( + Node.objects.filter( + topology=topology, + organization=org, + ).count(), + 3, + ) + self.assertEqual( + Link.objects.filter( + topology=topology, + organization=org, + ).count(), + 3, + ) + # Change mesh_id reported in the monitoring data + mesh_data = deepcopy(SIMPLE_MESH_DATA) + for (device, interfaces) in zip(devices, mesh_data.values()): + interfaces[0]['wireless']['ssid'] = 'New Mesh' + response = self.client.post( + '{0}?key={1}&time={2}'.format( + reverse('monitoring:api_device_metric', args=[device.id]), + device.key, + now().utcnow().strftime('%d-%m-%Y_%H:%M:%S.%f'), + ), + data=json.dumps( + { + 'type': 'DeviceMonitoring', + 'interfaces': interfaces, + } + ), + content_type='application/json', + ) + self.assertEqual(response.status_code, 200) + create_mesh_topology.delay(organization_ids=(org.id,)) + self.assertEqual(Topology.objects.filter(organization=org).count(), 2) + self.assertEqual(WifiMesh.objects.count(), 2) + self.assertEqual(Node.objects.count(), 6) + self.assertEqual(Link.objects.count(), 6) + self.assertEqual(WifiMesh.objects.filter(mesh_id='New Mesh@11').count(), 1) + topology = Topology.objects.filter( + organization=org, wifimesh__mesh_id='New Mesh@11' + ).first() + self.assertEqual( + Node.objects.filter( + topology=topology, + organization=org, + ).count(), + 3, + ) + self.assertEqual( + Link.objects.filter( + topology=topology, + organization=org, + ).count(), + 3, + ) + + def test_discard_old_monitoring_data(self): + now_time = now() + with freeze_time(now_time - timedelta(minutes=20)): + devices, org = self._populate_mesh(SIMPLE_MESH_DATA) + self.assertEqual(Topology.objects.count(), 1) + topology = Topology.objects.first() + self.assertEqual(topology.node_set.count(), 3) + self.assertEqual(topology.link_set.filter(status='up').count(), 3) + + with freeze_time(now_time - timedelta(minutes=10)): + # Only two devices sent monitoring data + for device in devices[:2]: + response = self.client.post( + '{0}?key={1}&time={2}'.format( + reverse('monitoring:api_device_metric', args=[device.id]), + device.key, + now().utcnow().strftime('%d-%m-%Y_%H:%M:%S.%f'), + ), + data=json.dumps( + { + 'type': 'DeviceMonitoring', + 'interfaces': SIMPLE_MESH_DATA[device.mac_address], + } + ), + content_type='application/json', + ) + self.assertEqual(response.status_code, 200) + create_mesh_topology.delay(organization_ids=(org.id,)) + self.assertEqual(Topology.objects.count(), 1) + self.assertEqual(topology.node_set.count(), 3) + self.assertEqual(topology.link_set.filter(status='up').count(), 1) + + # No device is sending monitoring data + create_mesh_topology.delay(organization_ids=(org.id,)) + self.assertEqual(Topology.objects.count(), 1) + self.assertEqual(topology.node_set.count(), 3) + self.assertEqual(topology.link_set.filter(status='up').count(), 0) + + def test_topology_admin(self): + """ + Tests WifiMeshInlineAdmin is present in TopologyAdmin + when OPENWISP_NETWORK_TOPOLOGY_WIFI_MESH_INTEGRATION + is set to True. + + Note: This test is present here because TopologyAdmin class + cannot be patched based on app_settings.WIFI_MESH_INTEGRATION + once the project is initialized. + """ + admin = self._create_admin() + self.client.force_login(admin) + topology = self._create_topology() + response = self.client.get( + reverse(f'{self.prefix}_topology_change', args=[topology.id]) + ) + self.assertContains(response, 'Wifi mesh') diff --git a/openwisp_network_topology/integrations/device/tests/utils.py b/openwisp_network_topology/integrations/device/tests/utils.py new file mode 100644 index 00000000..6bab47f1 --- /dev/null +++ b/openwisp_network_topology/integrations/device/tests/utils.py @@ -0,0 +1,107 @@ +import swapper +from openwisp_controller.config.tests.utils import ( + CreateConfigTemplateMixin, + TestVpnX509Mixin, + TestWireguardVpnMixin, +) +from openwisp_ipam.tests import CreateModelsMixin as SubnetIpamMixin + +from openwisp_network_topology.tests.utils import CreateGraphObjectsMixin +from openwisp_users.tests.utils import TestOrganizationMixin + +Node = swapper.load_model('topology', 'Node') + +Topology = swapper.load_model('topology', 'Topology') + + +class TopologyTestMixin( + TestVpnX509Mixin, + TestWireguardVpnMixin, + SubnetIpamMixin, + CreateConfigTemplateMixin, + CreateGraphObjectsMixin, + TestOrganizationMixin, +): + topology_model = Topology + node_model = Node + + def _init_test_node( + self, + topology, + addresses=None, + label='test', + common_name=None, + create=True, + ): + if not addresses: + addresses = ['netjson_id'] + node = Node( + organization=topology.organization, + topology=topology, + label=label, + addresses=addresses, + properties={'common_name': common_name}, + ) + if create: + node.full_clean() + node.save() + return node + + def _init_wireguard_test_node(self, topology, addresses=[], create=True, **kwargs): + if not addresses: + addresses = ['public_key'] + properties = { + 'preshared_key': None, + 'endpoint': None, + 'latest_handsake': '0', + 'transfer_rx': '0', + 'transfer_tx': '0', + 'persistent_keepalive': 'off', + 'allowed_ips': ['10.0.0.2/32'], + } + properties.update(kwargs) + allowed_ips = properties.get('allowed_ips') + node = Node( + organization=topology.organization, + topology=topology, + label=','.join(allowed_ips), + addresses=addresses, + properties=properties, + ) + if create: + node.full_clean() + node.save() + return node + + def _create_wireguard_test_env(self, parser): + org = self._get_org() + device, _, _ = self._create_wireguard_vpn_template() + device.organization = org + topology = self._create_topology(organization=org, parser=parser) + return topology, device + + def _create_test_env(self, parser): + organization = self._get_org() + vpn = self._create_vpn(name='test VPN', organization=organization) + self._create_template( + name='VPN', + type='vpn', + vpn=vpn, + config=vpn.auto_client(), + default=True, + organization=organization, + ) + vpn2 = self._create_vpn(name='test VPN2', ca=vpn.ca, organization=organization) + self._create_template( + name='VPN2', + type='vpn', + vpn=vpn2, + config=vpn.auto_client(), + default=True, + organization=organization, + ) + device = self._create_device(organization=organization) + config = self._create_config(device=device) + topology = self._create_topology(organization=organization, parser=parser) + cert = config.vpnclient_set.first().cert + return topology, device, cert diff --git a/openwisp_network_topology/migrations/0005_default_operator_permission.py b/openwisp_network_topology/migrations/0005_default_operator_permission.py index 29ef293b..a77aedaf 100644 --- a/openwisp_network_topology/migrations/0005_default_operator_permission.py +++ b/openwisp_network_topology/migrations/0005_default_operator_permission.py @@ -1,15 +1,9 @@ from django.conf import settings -from django.contrib.auth.management import create_permissions from django.contrib.auth.models import Permission from django.db import migrations from swapper import dependency, split - -def create_default_permissions(apps, schema_editor): - for app_config in apps.get_app_configs(): - app_config.models_module = True - create_permissions(app_config, apps=apps, verbosity=0) - app_config.models_module = None +from . import create_default_permissions def assign_permissions_to_groups(apps, schema_editor): diff --git a/openwisp_network_topology/migrations/__init__.py b/openwisp_network_topology/migrations/__init__.py index aac99234..8d646a7f 100644 --- a/openwisp_network_topology/migrations/__init__.py +++ b/openwisp_network_topology/migrations/__init__.py @@ -1,5 +1,6 @@ # Manually written, used during migrations import swapper +from django.contrib.auth.management import create_permissions def get_model(apps, app_name, model): @@ -33,3 +34,10 @@ def fix_link_properties(apps, schema_editor): for link in Link.objects.all(): link.full_clean() link.save() + + +def create_default_permissions(apps, schema_editor): + for app_config in apps.get_app_configs(): + app_config.models_module = True + create_permissions(app_config, apps=apps, verbosity=0) + app_config.models_module = None diff --git a/requirements-test.txt b/requirements-test.txt index 682dbadf..a6f894ff 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -6,3 +6,5 @@ freezegun~=1.1.0 openwisp-monitoring @ https://github.com/openwisp/openwisp-monitoring/tarball/master openwisp-controller @ https://github.com/openwisp/openwisp-controller/tarball/master openwisp-utils[qa,selenium] @ https://github.com/openwisp/openwisp-utils/tarball/master +redis~=4.5.4 +django_redis~=5.2.0 diff --git a/runtests.py b/runtests.py index f40b948c..a67eaefc 100755 --- a/runtests.py +++ b/runtests.py @@ -17,6 +17,10 @@ args.insert(3, 'openwisp_network_topology.integrations.device') else: args.insert(2, 'openwisp2') + if os.environ.get('WIFI_MESH', False): + args.extend(['--tag', 'wifi_mesh']) + else: + args.extend(['--exclude-tag', 'wifi_mesh']) execute_from_command_line(args) sys.exit( pytest.main( diff --git a/tests/openwisp2/asgi.py b/tests/openwisp2/asgi.py index ae66f898..ffbe6713 100644 --- a/tests/openwisp2/asgi.py +++ b/tests/openwisp2/asgi.py @@ -1,12 +1,30 @@ from channels.auth import AuthMiddlewareStack from channels.routing import ProtocolTypeRouter, URLRouter +from django.conf import settings + +if 'openwisp_controller.geo' in settings.INSTALLED_APPS: + from openwisp_controller.routing import get_routes as get_controller_routes +else: + from openwisp_controller.connection.channels.routing import ( + get_routes as get_connection_routes, + ) + from openwisp_notifications.websockets.routing import ( + get_routes as get_notification_routes, + ) + + def get_controller_routes(): + return get_connection_routes() + get_notification_routes() + import openwisp_network_topology.routing application = ProtocolTypeRouter( { 'websocket': AuthMiddlewareStack( - URLRouter(openwisp_network_topology.routing.websocket_urlpatterns) + URLRouter( + openwisp_network_topology.routing.websocket_urlpatterns + + get_controller_routes() + ) ), } ) diff --git a/tests/openwisp2/sample_integration_device/migrations/0001_initial.py b/tests/openwisp2/sample_integration_device/migrations/0001_initial.py index ca3d80b6..48cdd141 100644 --- a/tests/openwisp2/sample_integration_device/migrations/0001_initial.py +++ b/tests/openwisp2/sample_integration_device/migrations/0001_initial.py @@ -46,5 +46,31 @@ class Migration(migrations.Migration): ), ], options={'abstract': False, 'unique_together': {('node', 'device')}}, - ) + ), + migrations.CreateModel( + name='WifiMesh', + fields=[ + ( + 'id', + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ('mesh_id', models.CharField(max_length=32, verbose_name='Mesh ID')), + ('is_test', models.BooleanField(default=True)), + ( + 'topology', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.TOPOLOGY_TOPOLOGY_MODEL, + ), + ), + ], + options={ + 'abstract': False, + }, + ), ] diff --git a/tests/openwisp2/sample_integration_device/models.py b/tests/openwisp2/sample_integration_device/models.py index e7c08c87..09cd6cd3 100644 --- a/tests/openwisp2/sample_integration_device/models.py +++ b/tests/openwisp2/sample_integration_device/models.py @@ -1,6 +1,9 @@ from django.db import models -from openwisp_network_topology.integrations.device.base.models import AbstractDeviceNode +from openwisp_network_topology.integrations.device.base.models import ( + AbstractDeviceNode, + AbstractWifiMesh, +) class DeviceNode(AbstractDeviceNode): @@ -8,3 +11,10 @@ class DeviceNode(AbstractDeviceNode): class Meta(AbstractDeviceNode.Meta): abstract = False + + +class WifiMesh(AbstractWifiMesh): + is_test = models.BooleanField(default=True) + + class Meta(AbstractWifiMesh.Meta): + abstract = False diff --git a/tests/openwisp2/sample_integration_device/tests.py b/tests/openwisp2/sample_integration_device/tests.py index e416c942..18b4d749 100644 --- a/tests/openwisp2/sample_integration_device/tests.py +++ b/tests/openwisp2/sample_integration_device/tests.py @@ -1,12 +1,12 @@ import swapper -from openwisp_network_topology.integrations.device.tests import ( +from openwisp_network_topology.integrations.device.tests.test_integration import ( TestAdmin as BaseTestAdmin, ) -from openwisp_network_topology.integrations.device.tests import ( +from openwisp_network_topology.integrations.device.tests.test_integration import ( TestControllerIntegration as BaseTestControllerIntegration, ) -from openwisp_network_topology.integrations.device.tests import ( +from openwisp_network_topology.integrations.device.tests.test_integration import ( TestMonitoringIntegration as BaseTestMonitoringIntegration, ) diff --git a/tests/openwisp2/settings.py b/tests/openwisp2/settings.py index 457730c0..d9845465 100644 --- a/tests/openwisp2/settings.py +++ b/tests/openwisp2/settings.py @@ -4,17 +4,19 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) TESTING = 'test' in sys.argv + DEBUG = True -ALLOWED_HOSTS = [] +ALLOWED_HOSTS = ['*'] DATABASES = { 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': 'openwisp_network_topology.db', + 'ENGINE': 'django.contrib.gis.db.backends.spatialite', + 'NAME': os.path.join(BASE_DIR, 'openwisp_network_topology.db'), } } + SECRET_KEY = '@q4z-^s=mv59#o=uutv4*m=h@)ik4%zp1)-k^_(!_7*x_&+ze$' INSTALLED_APPS = [ @@ -47,6 +49,7 @@ 'import_export', 'admin_auto_filters', 'django.contrib.admin', + 'django.forms', # rest framework 'rest_framework', 'drf_yasg', @@ -58,6 +61,9 @@ 'channels', ] + +EXTENDED_APPS = ['django_x509', 'django_loci'] + AUTH_USER_MODEL = 'openwisp_users.User' SITE_ID = 1 @@ -85,6 +91,7 @@ ASGI_APPLICATION = 'openwisp2.asgi.application' CHANNEL_LAYERS = {'default': {'BACKEND': 'channels.layers.InMemoryChannelLayer'}} +FORM_RENDERER = 'django.forms.renderers.TemplatesSetting' LANGUAGE_CODE = 'en-gb' TIME_ZONE = 'UTC' @@ -108,7 +115,7 @@ 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', - 'openwisp_utils.admin_theme.context_processor.menu_items', + 'openwisp_utils.admin_theme.context_processor.menu_groups', ], }, } @@ -184,6 +191,37 @@ } ) +# Avoid adding unnecessary dependency to speedup tests. +if not TESTING or (TESTING and os.environ.get('WIFI_MESH', False)): + OPENWISP_NETWORK_TOPOLOGY_WIFI_MESH_INTEGRATION = True + INSTALLED_APPS.insert( + INSTALLED_APPS.index('openwisp_controller.connection'), + 'openwisp_controller.geo', + ) + openwisp_ipam_index = INSTALLED_APPS.index('openwisp_ipam') + INSTALLED_APPS.insert(openwisp_ipam_index, 'leaflet') + INSTALLED_APPS.insert(openwisp_ipam_index, 'openwisp_monitoring.check') + INSTALLED_APPS.insert(openwisp_ipam_index, 'openwisp_monitoring.device') + INSTALLED_APPS.insert(openwisp_ipam_index, 'openwisp_monitoring.monitoring') + TIMESERIES_DATABASE = { + 'BACKEND': 'openwisp_monitoring.db.backends.influxdb', + 'USER': 'openwisp', + 'PASSWORD': 'openwisp', + 'NAME': 'openwisp2', + 'HOST': os.getenv('INFLUXDB_HOST', 'localhost'), + 'PORT': '8086', + } + OPENWISP_MONITORING_MAC_VENDOR_DETECTION = False + CACHES = { + 'default': { + 'BACKEND': 'django_redis.cache.RedisCache', + 'LOCATION': 'redis://localhost/9', + 'OPTIONS': { + 'CLIENT_CLASS': 'django_redis.client.DefaultClient', + }, + } + } + if os.environ.get('SAMPLE_APP', False): INSTALLED_APPS.remove('openwisp_network_topology') INSTALLED_APPS.remove('openwisp_network_topology.integrations.device') @@ -197,9 +235,10 @@ TOPOLOGY_SNAPSHOT_MODEL = 'sample_network_topology.Snapshot' TOPOLOGY_TOPOLOGY_MODEL = 'sample_network_topology.Topology' TOPOLOGY_DEVICE_DEVICENODE_MODEL = 'sample_integration_device.DeviceNode' + TOPOLOGY_DEVICE_WIFIMESH_MODEL = 'sample_integration_device.WifiMesh' # local settings must be imported before test runner otherwise they'll be ignored try: - from local_settings import * + from .local_settings import * except ImportError: pass diff --git a/tests/openwisp2/urls.py b/tests/openwisp2/urls.py index 894c9a2f..b387351a 100644 --- a/tests/openwisp2/urls.py +++ b/tests/openwisp2/urls.py @@ -26,7 +26,8 @@ ] urlpatterns += staticfiles_urlpatterns() - +if 'openwisp_monitoring.monitoring' in settings.INSTALLED_APPS: + urlpatterns.append(path('', include('openwisp_monitoring.urls'))) if settings.DEBUG and 'debug_toolbar' in settings.INSTALLED_APPS: import debug_toolbar