From c63d8e1bff9e9b08183d644550b57e767e656e5e Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Sat, 3 Aug 2024 23:09:33 +0530 Subject: [PATCH] [docs] Restructured Documentation Restructured the documentation in a way that allows it to be included in the Unified Documentation of OpenWISP. For more information see https://github.com/openwisp/openwisp-docs/issues/107. --------- Co-authored-by: Federico Capoano --- README.rst | 1424 +---------------- docs/developer/extending.rst | 412 +++++ docs/developer/index.rst | 16 + docs/developer/installation.rst | 119 ++ .../overriding-visualizer-templates.rst | 53 + ...hitecture-v2-openwisp-network-topology.png | Bin 0 -> 398547 bytes docs/index.rst | 57 + docs/partials/developer-docs.rst | 13 + docs/user/integrations.rst | 73 + docs/user/intro.rst | 32 + docs/user/management-commands.rst | 101 ++ docs/user/quickstart.rst | 193 +++ docs/user/rest-api.rst | 259 +++ docs/user/settings.rst | 132 ++ docs/user/strategies.rst | 48 + pyproject.toml | 2 +- 16 files changed, 1547 insertions(+), 1387 deletions(-) create mode 100644 docs/developer/extending.rst create mode 100644 docs/developer/index.rst create mode 100644 docs/developer/installation.rst create mode 100644 docs/developer/overriding-visualizer-templates.rst create mode 100644 docs/images/architecture-v2-openwisp-network-topology.png create mode 100644 docs/index.rst create mode 100644 docs/partials/developer-docs.rst create mode 100644 docs/user/integrations.rst create mode 100644 docs/user/intro.rst create mode 100644 docs/user/management-commands.rst create mode 100644 docs/user/quickstart.rst create mode 100644 docs/user/rest-api.rst create mode 100644 docs/user/settings.rst create mode 100644 docs/user/strategies.rst diff --git a/README.rst b/README.rst index 90cc71ef..333d6a48 100644 --- a/README.rst +++ b/README.rst @@ -1,1428 +1,80 @@ -========================= openwisp-network-topology ========================= .. image:: https://github.com/openwisp/openwisp-network-topology/workflows/OpenWISP%20Network%20Topology%20CI%20Build/badge.svg?branch=master - :target: https://github.com/openwisp/openwisp-network-topology/actions?query=OpenWISP+Network+Topology+CI+Build - :alt: CI build status + :target: https://github.com/openwisp/openwisp-network-topology/actions?query=OpenWISP+Network+Topology+CI+Build + :alt: CI build status .. image:: https://coveralls.io/repos/github/openwisp/openwisp-network-topology/badge.svg - :target: https://coveralls.io/github/openwisp/openwisp-network-topology - :alt: Test Coverage + :target: https://coveralls.io/github/openwisp/openwisp-network-topology + :alt: Test Coverage .. image:: https://img.shields.io/librariesio/github/openwisp/openwisp-network-topology - :target: https://libraries.io/github/openwisp/openwisp-network-topology#repository_dependencies - :alt: Dependency monitoring + :target: https://libraries.io/github/openwisp/openwisp-network-topology#repository_dependencies + :alt: Dependency monitoring .. image:: https://img.shields.io/gitter/room/nwjs/nw.js.svg - :target: https://gitter.im/openwisp/general - :alt: chat + :target: https://gitter.im/openwisp/general + :alt: chat .. image:: https://badge.fury.io/py/openwisp-network-topology.svg - :target: http://badge.fury.io/py/openwisp-network-topology - :alt: Pypi Version + :target: http://badge.fury.io/py/openwisp-network-topology + :alt: Pypi Version .. image:: https://pepy.tech/badge/openwisp-network-topology - :target: https://pepy.tech/project/openwisp-network-topology - :alt: downloads + :target: https://pepy.tech/project/openwisp-network-topology + :alt: downloads .. image:: https://img.shields.io/badge/code%20style-black-000000.svg - :target: https://pypi.org/project/black/ - :alt: code style: black + :target: https://pypi.org/project/black/ + :alt: code style: black .. image:: https://github.com/openwisp/openwisp-network-topology/raw/docs/docs/demo_network_topology.gif - :alt: Features Highlights - -**Need a quick overview?** `Try the OpenWISP Demo `_. - -OpenWISP Network Topology is a network topology collector and visualizer -web application and API, it allows to collect network topology data from different -networking software (dynamic mesh routing protocols, OpenVPN), store it, -visualize it, edit its details, it also provides hooks (a.k.a -`Django signals `_) -to execute code when the status of a link changes. - -When used in conjunction with -`OpenWISP Controller `_ -and -`OpenWISP Monitoring `_, -it -`makes the monitoring system faster in detecting change to the network <#integration-with-openwisp-controller-and-openwisp-monitoring>`_. + :alt: Features Highlights -OpenWISP is not only an application designed for end users, but can also be -used as a framework on which custom network automation solutions can be built -on top of its building blocks. - -Other popular building blocks that are part of the OpenWISP ecosystem are: - -- `openwisp-controller `_: - network and WiFi controller: provisioning, configuration management, - x509 PKI management and more; works on OpenWRT, but designed to work also on other systems. -- `openwisp-monitoring `_: - provides device status monitoring, collection of metrics, charts, alerts, - possibility to define custom checks -- `openwisp-firmware-upgrader `_: - automated firmware upgrades (single device or mass network upgrades) -- `openwisp-radius `_: - based on FreeRADIUS, allows to implement network access authentication systems like - 802.1x WPA2 Enterprise, captive portal authentication, Hotspot 2.0 (802.11u) -- `openwisp-ipam `_: - it allows to manage the IP address space of networks - -**For a more complete overview of the OpenWISP modules and architecture**, -see the -`OpenWISP Architecture Overview -`_. - -.. image:: https://raw.githubusercontent.com/openwisp/openwisp2-docs/master/assets/design/openwisp-logo-black.svg - :target: http://openwisp.org - :alt: OpenWISP +**Need a quick overview?** `Try the OpenWISP Demo +`_. **Want to help OpenWISP?** `Find out how to help us grow here `_. ------------- - -.. contents:: **Table of Contents**: - :backlinks: none - :depth: 3 - ------------- - -Available features ------------------- - -* **network topology collector** supporting different formats: - - NetJSON NetworkGraph - - OLSR (jsoninfo/txtinfo) - - batman-adv (jsondoc/txtinfo) - - BMX6 (q6m) - - CNML 1.0 - - OpenVPN - - Wireguard - - ZeroTier - - additional formats can be added by - `writing custom netdiff parsers `_ -* **network topology visualizer** based on - `netjsongraph.js `_ -* `REST API <#rest-api>`_ that exposes data in - `NetJSON `__ *NetworkGraph* format -* **admin interface** that allows to easily manage, audit, visualize and - debug topologies and their relative data (nodes, links) -* `RECEIVE network topology data <#receive-strategy>`_ from multiple nodes -* **topology history**: allows saving daily snapshots of each topology that - can be viewed in the frontend -* **faster monitoring**: `integrates with OpenWISP Controller and OpenWISP Monitoring - <#integration-with-openwisp-controller-and-openwisp-monitoring>`_ - for faster detection of critical events in the network - -Installation instructions -------------------------- - -Deploy it in production -^^^^^^^^^^^^^^^^^^^^^^^ - -An automated installer is provided by the `OpenWISP `_ project: -`ansible-openwisp2 `_. - -Ensure to follow the instructions explained in the following section: `Enabling the network topology -module `_. - -Install stable version from pypi -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Install from pypi: - -.. code-block:: shell - - pip install openwisp-network-topology - -Install development version -^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Install tarball: - -.. code-block:: shell - - pip install https://github.com/openwisp/openwisp-network-topology/tarball/master - -Alternatively you can install via pip using git: - -.. code-block:: shell - - pip install -e git+git://github.com/openwisp/openwisp-network-topology#egg=openwisp-network-topology - -If you want to contribute, install your cloned fork: - -.. code-block:: shell - - git clone git@github.com:/openwisp-network-topology.git - cd openwisp-network-topology - python setup.py develop - -Installing for development -^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Install sqlite: - -.. code-block:: shell - - 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: - -.. code-block:: shell - - git clone git://github.com//openwisp-network-topology - 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 - - pip install -r requirements-test.txt - -Create database: - -.. code-block:: shell - - cd tests/ - ./manage.py migrate - ./manage.py createsuperuser - -You can access the admin interface at http://127.0.0.1:8000/admin/. - -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: - -.. code-block:: shell - - ./run-qa-checks - -Setup (integrate in an existing django project) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Add ``openwisp_network_topology`` and its dependencies to ``INSTALLED_APPS``: - -.. code-block:: python - - INSTALLED_APPS = [ - # other apps - 'openwisp_network_topology', - 'openwisp_users.accounts', - 'allauth', - 'allauth.account', - 'openwisp_users', - 'rest_framework', - ] - -Add the URLs to your main ``urls.py``: - -.. code-block:: python - - from django.contrib import admin - - urlpatterns = [ - # ... other urls in your project ... - path('', include('openwisp_network_topology.urls')), - path('admin/', admin.site.urls), - ] - -Then run: - -.. code-block:: shell - - ./manage.py migrate - -Quickstart Guide ----------------- - -This module works by periodically collecting the network topology -graph data of the `supported networking software or formats <#available-features>`_. -The data has to be either fetched by the application or received in POST API -requests, therefore after deploying the application, additional steps are required -to make the data collection and visualization work, read on to find out how. - -Creating a topology -^^^^^^^^^^^^^^^^^^^ - -.. image:: https://github.com/openwisp/openwisp-network-topology/raw/docs/docs/quickstart-topology.gif - -1. Create a topology object by going to *Network Topology* > *Topologies* - > *Add topology*. -2. Give an appropriate label to the topology. -3. Select the *topology format* from the dropdown menu. The *topology format* - determines which parser should be used to process topology data. -4. Select the *Strategy* for updating this topology. - - - If you are using `FETCH strategy <#fetch-strategy>`_, then enter the - URL for fetching topology data in the *Url* field. - - If you are using `RECEIVE strategy <#receive-strategy>`_, you will get the - *URL* for sending topology data. The *RECEIVE* strategy provides an - additional field *expiration time*. This can be used to add delay in - marking missing links as down. - -Sending data for topology with RECEIVE strategy -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. image:: https://github.com/openwisp/openwisp-network-topology/raw/docs/docs/quickstart-receive.gif - -1. Copy the *URL* generated by OpenWISP for sending the topology data. - - E.g., in our case the URL is ``http://127.0.0.1:8000/api/v1/network-topology/topology/d17e539a-1793-4be2-80a4-c305eca64fd8/receive/?key=cMGsvio8q0L0BGLd5twiFHQOqIEKI423``. - - **Note:** The topology receive URL is shown only after the topology object is created. - -2. Create a script (eg: ``/opt/send-topology.sh``) which sends the topology - data using ``POST``, in the example script below we are sending the - status log data of OpenVPN but the same code can be applied to other - formats by replacing ``cat /var/log/openvpn/tun0.stats`` with the - actual command which returns the network topology output: - -.. code-block:: shell - - #!/bin/bash - # replace COMMAND with the command used to fetch the topology data - COMMAND="cat /var/log/openvpn/tun0.stats" - UUID="" - KEY="" - OPENWISP_URL="https://" - $COMMAND | - # Upload the topology data to OpenWISP - curl -X POST \ - --data-binary @- \ - --header "Content-Type: text/plain" \ - $OPENWISP_URL/api/v1/network-topology/topology/$UUID/receive/?key=$KEY - -3. Add the ``/opt/send-topology.sh`` script created in the previous step - to the crontab, here's an example which sends the topology data every 5 minutes: - -.. code-block:: shell - - # flag script as executable - chmod +x /opt/send-topology.sh - # open crontab - crontab -e - - ## Add the following line and save - - echo */5 * * * * /opt/send-topology.sh - -4. Once the steps above are completed, you should see nodes and links - being created automatically, you can see the network topology graph - from the admin page of the topology change page - (you have to click on the *View topology graph* button in the upper - right part of the page) - or, alternatively, a non-admin visualizer page is also available at - the URL ``/topology/topology//``. - -Sending data for ZeroTier topology with RECEIVE strategy -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Follow the procedure described below to setup ZeroTier topology with RECEIVE strategy. - -**Note:** In this example, the **Shared systemwide (no organization)** -option is used for the ZeroTier topology organization. You are free to -opt for any organization, as long as both the topology and the device share -the same organization, assuming the `OpenWISP controller integration -<#integration-with-openwisp-controller-and-openwisp-monitoring>`_ feature is enabled. - -1. Create topology for ZeroTier -############################### - -1. Visit ``admin/topology/topology/add`` to add a new topology. - -2. We will set the **Label** of this topology to ``ZeroTier`` and - select the topology **Format** from the dropdown as ``ZeroTier``. - -3. Select the strategy as ``RECEIVE`` from the dropdown. - -.. image:: https://raw.githubusercontent.com/openwisp/openwisp-network-topology/docs/docs/zerotier-tutorial/topology-1.png - :alt: ZeroTier topology configuration example 1 - -4. Let use default **Expiration time** ``0`` and make sure **Published** option is checked. - -5. After clicking on the **Save and continue editing** button, a topology receive URL is generated. - Make sure you copy that URL for later use in the topology script. - -.. image:: https://raw.githubusercontent.com/openwisp/openwisp-network-topology/docs/docs/zerotier-tutorial/topology-2.png - :alt: ZeroTier topology configuration example 2 - -2. Create a script for sending ZeroTier topology data -##################################################### - -1. Now, create a script (e.g: ``/opt/send-zt-topology.sh``) that sends - the ZeroTier topology data using a POST request. In the example script below, - we are sending the ZeroTier self-hosted controller peers data: - -.. code-block:: shell - - #!/bin/bash - # command to fetch zerotier controller peers data in json format - COMMAND="zerotier-cli peers -j" - UUID="" - KEY="" - OPENWISP_URL="https://" - $COMMAND | - # Upload the topology data to OpenWISP - curl -X POST \ - --data-binary @- \ - --header "Content-Type: text/plain" \ - $OPENWISP_URL/api/v1/network-topology/topology/$UUID/receive/?key=$KEY - -2. Add the ``/opt/send-zt-topology.sh`` script created in the previous step - to the root crontab, here's an example which sends the topology data every **5 minutes**: - -.. code-block:: shell - - # flag script as executable - chmod +x /opt/send-zt-topology.sh - -.. code-block:: shell - - # open rootcrontab - sudo crontab -e - - ## Add the following line and save - - echo */5 * * * * /opt/send-zt-topology.sh - -**Note:** When using the **ZeroTier** topology, ensure that -you use ``sudo crontab -e`` to edit the **root crontab**. This step -is essential because the ``zerotier-cli peers -j`` command requires **root privileges** -for kernel interaction, without which the command will not function correctly. - -3. Once the steps above are completed, you should see nodes and links - being created automatically, you can see the network topology graph - from the admin page of the topology change page (you have to click on - the **View topology graph** button in the upper right part of the page) - or, alternatively, a non-admin visualizer page is also available at - the URL ``/topology/topology//``. - - .. image:: https://raw.githubusercontent.com/openwisp/openwisp-network-topology/docs/docs/zerotier-tutorial/topology-graph.png - :alt: ZeroTier topology graph example 1 - -Management Commands -------------------- - -``update_topology`` -^^^^^^^^^^^^^^^^^^^ - -After topology URLs (URLs exposing the files that the topology of the network) have been -added in the admin, the ``update_topology`` management command can be used to collect data -and start playing with the network graph:: - - ./manage.py update_topology - -The management command accepts a ``--label`` argument that will be used to search in -topology labels, eg:: - - ./manage.py update_topology --label mytopology - -``save_snapshot`` -^^^^^^^^^^^^^^^^^ - -The ``save_snapshot`` management command can be used to save the topology graph data which -could be used to view the network topology graph sometime in future:: - - ./manage.py save_snapshot - -The management command accepts a ``--label`` argument that will be used to search in -topology labels, eg:: - - ./manage.py save_snapshot --label mytopology - -``upgrade_from_django_netjsongraph`` -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -If you are upgrading from django-netjsongraph to openwisp-network-topology, there -is an easy migration script that will import your topologies, users & groups to -openwisp-network-topology instance:: - - ./manage.py upgrade_from_django_netjsongraph - -The management command accepts an argument ``--backup``, that you can pass -to give the location of the backup files, by default it looks in the ``tests/`` -directory, eg:: - - ./manage.py upgrade_from_django_netjsongraph --backup /home/user/django_netjsongraph/ - -The management command accepts another argument ``--organization``, if you want to -import data to a specific organization, you can give its UUID for the same, -by default the data is added to the first found organization, eg:: - - ./manage.py upgrade_from_django_netjsongraph --organization 900856da-c89a-412d-8fee-45a9c763ca0b - -**Note**: you can follow the `tutorial to migrate database from django-netjsongraph `_. - -``create_device_nodes`` -^^^^^^^^^^^^^^^^^^^^^^^ - -This management command can be used to create the initial ``DeviceNode`` relationships when the -`integration with OpenWISP Controller <#integration-with-openwisp-controller-and-openwisp-monitoring>`_ -is enabled in a pre-existing system which already has some devices and topology objects in its database. - -.. code-block:: shell - - ./manage.py create_device_nodes - -Logging -------- - -The ``update_topology`` management command will automatically try to log errors. - -For a good default ``LOGGING`` configuration refer to the `test settings -`_. - -Strategies ----------- - -There are mainly two ways of collecting topology information: - -* **FETCH** strategy -* **RECEIVE** strategy - -Each ``Topology`` instance has a ``strategy`` field which can be set to the desired setting. - -FETCH strategy -^^^^^^^^^^^^^^ - -Topology data will be fetched from a URL. - -When some links are not detected anymore they will be flagged as "down" straightaway. - -RECEIVE strategy -^^^^^^^^^^^^^^^^ - -Topology data is sent directly from one or more nodes of the network. - -The collector waits to receive data in the payload of a POST HTTP request; -when such a request is received, a ``key`` parameter it's first checked against -the ``Topology`` key. - -If the request is authorized the collector proceeds to update the topology. - -If the data is sent from one node only, it's highly advised to set the -``expiration_time`` of the ``Topology`` instance to ``0`` (seconds), this way the -system works just like in the **FETCH strategy**, with the only difference that -the data is sent by one node instead of fetched by the collector. - -If the data is sent from multiple nodes, you **SHOULD** set the ``expiration_time`` -of the ``Topology`` instance to a value slightly higher than the interval used -by nodes to send the topology, this way links will be flagged as "down" only if -they haven't been detected for a while. This mechanism allows to visualize the -topology even if the network has been split in several parts, the disadvantage -is that it will take a bit more time to detect links that go offline. - -Integration with OpenWISP Controller and OpenWISP Monitoring ------------------------------------------------------------- - -If you use `OpenWISP Controller `_ -or `OpenWISP Monitoring `_ -and you use OpenVPN, Wireguard or ZeroTier for the management VPN, you can use -the integration available in ``openwisp_network_topology.integrations.device``. - -This additional and optional module provides the following features: - -- whenever the status of a link changes: - - - 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 -``openwisp_network_topology.integrations.device`` to ``INSTALLED_APPS``: - -.. code-block:: python - - INSTALLED_APPS = [ - # other apps (eg: openwisp-controller, openwisp-monitoring) - 'openwisp_network_topology', - 'openwisp_network_topology.integrations.device', - 'openwisp_users.accounts', - 'allauth', - 'allauth.account', - 'openwisp_users', - '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. - -Settings --------- - -``OPENWISP_NETWORK_TOPOLOGY_PARSERS`` -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -+--------------+-------------+ -| **type**: | ``list`` | -+--------------+-------------+ -| **default**: | ``[]`` | -+--------------+-------------+ - -Additional custom `netdiff parsers `_. - -``OPENWISP_NETWORK_TOPOLOGY_SIGNALS`` -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -+--------------+-------------+ -| **type**: | ``str`` | -+--------------+-------------+ -| **default**: | ``None`` | -+--------------+-------------+ - -String representing python module to import on initialization. - -Useful for loading django signals or to define custom behaviour. - -``OPENWISP_NETWORK_TOPOLOGY_TIMEOUT`` -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -+--------------+-------------+ -| **type**: | ``int`` | -+--------------+-------------+ -| **default**: | ``8`` | -+--------------+-------------+ - -Timeout when fetching topology URLs. - -``OPENWISP_NETWORK_TOPOLOGY_LINK_EXPIRATION`` -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -+--------------+-------------+ -| **type**: | ``int`` | -+--------------+-------------+ -| **default**: | ``60`` | -+--------------+-------------+ - -If a link is down for more days than this number, it will be deleted by the -``update_topology`` management command. - -Setting this to ``False`` will disable this feature. - -``OPENWISP_NETWORK_TOPOLOGY_NODE_EXPIRATION`` -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -+--------------+--------------------------------+ -| **type**: | ``int`` | -+--------------+--------------------------------+ -| **default**: | ``False`` | -+--------------+--------------------------------+ - -If a node has not been modified since the days specified and if it has no links, -it will be deleted by the ``update_topology`` management command. This depends on -``OPENWISP_NETWORK_TOPOLOGY_LINK_EXPIRATION`` being enabled. -Replace ``False`` with an integer to enable the feature. - -``OPENWISP_NETWORK_TOPOLOGY_VISUALIZER_CSS`` -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -+--------------+--------------------------------+ -| **type**: | ``str`` | -+--------------+--------------------------------+ -| **default**: | ``netjsongraph/css/style.css`` | -+--------------+--------------------------------+ - -Path of the visualizer css file. Allows customization of css according to user's -preferences. - -``OPENWISP_NETWORK_TOPOLOGY_API_URLCONF`` -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -+--------------+---------------+ -| **type**: | ``string`` | -+--------------+---------------+ -| **default**: | ``None`` | -+--------------+---------------+ - -Use the ``urlconf`` option to change receive api url to point to -another module, example, ``myapp.urls``. - -``OPENWISP_NETWORK_TOPOLOGY_API_BASEURL`` -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -+--------------+---------------+ -| **type**: | ``string`` | -+--------------+---------------+ -| **default**: | ``None`` | -+--------------+---------------+ - -If you have a seperate instance of openwisp-network-topology on a -different domain, you can use this option to change the base -of the url, this will enable you to point all the API urls to -your openwisp-network-topology API server's domain, -example value: ``https://mytopology.myapp.com``. - -``OPENWISP_NETWORK_TOPOLOGY_API_AUTH_REQUIRED`` -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -+--------------+---------------+ -| **type**: | ``boolean`` | -+--------------+---------------+ -| **default**: | ``True`` | -+--------------+---------------+ - -When enabled, the API `endpoints <#list-of-endpoints>`_ will only allow authenticated users -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 --------- - -Live documentation -^^^^^^^^^^^^^^^^^^ - -.. image:: https://github.com/openwisp/openwisp-network-topology/raw/docs/docs/api-doc.png - -A general live API documentation (following the OpenAPI specification) at ``/api/v1/docs/``. - -Browsable web interface -^^^^^^^^^^^^^^^^^^^^^^^ +---- -.. image:: https://github.com/openwisp/openwisp-network-topology/raw/docs/docs/api-ui.png - -Additionally, opening any of the endpoints `listed below <#list-of-endpoints>`_ -directly in the browser will show the `browsable API interface of Django-REST-Framework -`_, -which makes it even easier to find out the details of each endpoint. - -List of endpoints -^^^^^^^^^^^^^^^^^ - -Since the detailed explanation is contained in the `Live documentation <#live-documentation>`_ -and in the `Browsable web page <#browsable-web-interface>`_ of each point, -here we'll provide just a list of the available endpoints, -for further information please open the URL of the endpoint in your browser. - -List topologies -############### - -.. code-block:: text - - GET /api/v1/network-topology/topology/ - -Available filters: - -- ``strategy``: Filter topologies based on their strategy (``fetch`` or ``receive``). - E.g. ``?strategy=``. -- ``parser``: Filter topologies based on their parser. - E.g. ``?parser=``. -- ``organization``: Filter topologies based on their organization. - E.g. ``?organization=``. -- ``organization_slug``: Filter topologies based on their organization slug. - E.g. ``?organization_slug=``. - -You can use multiple filters in one request, e.g.: - -.. code-block:: text - - /api/v1/network-topology/topology/?organization=371791ec-e3fe-4c9a-8972-3e8b882416f6&strategy=fetch - -**Note**: By default, ``/api/v1/network-topology/topology/`` does not include -unpublished topologies. If you want to include unpublished topologies in the -response, use ``?include_unpublished=true`` filter as following: - -.. code-block:: text - - GET /api/v1/network-topology/topology/?include_unpublished=true - -Create topology -############### - -.. code-block:: text - - POST /api/v1/network-topology/topology/ - -Detail of a topology -#################### - -.. code-block:: text - - GET /api/v1/network-topology/topology/{id}/ - -**Note**: By default, ``/api/v1/network-topology/topology/{id}/`` will return -``HTTP 404 Not Found`` for unpublished topologies. If you want to retrieve an -unpublished topology, use ``?include_unpublished=true`` filter as following: - -.. code-block:: text - - GET /api/v1/network-topology/topology/{id}/?include_unpublished=true - -Change topolgy detail -##################### - -.. code-block:: text - - PUT /api/v1/network-topology/topology/{id}/ - -Patch topology detail -##################### - -.. code-block:: text - - PATCH /api/v1/network-topology/topology/{id}/ - -Delete topology -############### - -.. code-block:: text - - DELETE /api/v1/network-topology/topology/{id}/ - -View topology history -##################### - -This endpoint is used to go back in time to view previous topology snapshots. -For it to work, snapshots need to be saved periodically as described in -`save_snapshot <#save-snapshot>`_ section above. - -For example, we could use the endpoint to view the snapshot of a topology -saved on ``2020-08-08`` as follows. - -.. code-block:: text - - GET /api/v1/network-topology/topology/{id}/history/?date=2020-08-08 - -Send topology data -################## - -.. code-block:: text - - POST /api/v1/network-topology/topology/{id}/receive/ - -List links -########## - -.. code-block:: text - - GET /api/v1/network-topology/link/ - -Available filters: - -- ``topology``: Filter links belonging to a topology. - E.g. ``?topology=``. -- ``organization``: Filter links belonging to an organization. - E.g. ``?organization=``. -- ``organization_slug``: Filter links based on their organization slug. - E.g. ``?organization_slug=``. -- ``status``: Filter links based on their status (``up`` or ``down``). - E.g. ``?status=``. - -You can use multiple filters in one request, e.g.: - -.. code-block:: text - - /api/v1/network-topology/link/?status=down&topology=7fce01bd-29c0-48b1-8fce-0508f2d75d36 - -Create link -########### - -.. code-block:: text - - POST /api/v1/network-topology/link/ - -Get link detail -############### - -.. code-block:: text - - GET /api/v1/network-topology/link/{id}/ - -Change link detail -################## - -.. code-block:: text - - PUT /api/v1/network-topology/link/{id}/ - -Patch link detail -################# - -.. code-block:: text - - PATCH /api/v1/network-topology/link/{id}/ - -Delete link -########### - -.. code-block:: text - - DELETE /api/v1/network-topology/link/{id}/ - -List nodes -########## - -.. code-block:: text - - GET /api/v1/network-topology/node/ - -Available filters: - -- ``topology``: Filter nodes belonging to a topology. - E.g. ``?topology=``. -- ``organization``: Filter nodes belonging to an organization. - E.g. ``?organization=``. -- ``organization_slug``: Filter nodes based on their organization slug. - E.g. ``?organization_slug=``. - -You can use multiple filters in one request, e.g.: - -.. code-block:: text - - /api/v1/network-topology/node/?organization=371791ec-e3fe-4c9a-8972-3e8b882416f6&topology=7fce01bd-29c0-48b1-8fce-0508f2d75d36 - -Create node -########### - -.. code-block:: text - - POST /api/v1/network-topology/node/ - -Get node detail -############### - -.. code-block:: text - - GET /api/v1/network-topology/node/{id}/ - -Change node detail -################## - -.. code-block:: text - - PUT /api/v1/network-topology/node/{id}/ - -Patch node detail -################# - -.. code-block:: text - - PATCH /api/v1/network-topology/node/{id}/ - -Delete node -########### - -.. code-block:: text - - DELETE /api/v1/network-topology/node/{id}/ - -Overriding visualizer templates -------------------------------- - -Follow these steps to override and customise the visualizer's default templates: - -* create a directory in your django project and put its full path in ``TEMPLATES['DIRS']``, - which can be found in the django ``settings.py`` file -* create a sub directory named ``netjsongraph`` and add all the templates which shall override - the default ``netjsongraph/*`` templates -* create a template file with the same name of the template file you want to override - -More information about the syntax used in django templates can be found in the `django templates -documentation `_. - -Example: overriding the `` - -Extending openwisp-network-topology ------------------------------------ - -One of the core values of the OpenWISP project is `Software Reusability `_, -for this reason *openwisp-network-topology* provides a set of base classes -which can be imported, extended and reused to create derivative apps. - -In order to implement your custom version of *openwisp-network-topology*, -you need to perform the steps described in this section. - -When in doubt, the code in the `test project `_ -and the `sample app `_ -will serve you as source of truth: -just replicate and adapt that code to get a basic derivative of -*openwisp-network-topology* working. - -**Premise**: if you plan on using a customized version of this module, -we suggest to start with it since the beginning, because migrating your data -from the default module to your extended version may be time consuming. - -1. Initialize your custom module -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -The first thing you need to do is to create a new django app which will -contain your custom version of *openwisp-network-topology*. - -A django app is nothing more than a -`python package `_ -(a directory of python scripts), in the following examples we'll call this django app -``sample_network_topology``, but you can name it how you want:: - - django-admin startapp sample_network_topology - -If you use the integration with openwisp-controller, you may want to extend also the -integration app if you need:: - - django-admin startapp sample_integration_device - -Keep in mind that the command mentioned above must be called from a directory -which is available in your `PYTHON_PATH `_ -so that you can then import the result into your project. - -Now you need to add ``sample_network_topology`` to ``INSTALLED_APPS`` in your ``settings.py``, -ensuring also that ``openwisp_network_topology`` has been removed: - -.. code-block:: python - - INSTALLED_APPS = [ - # ... other apps ... - 'openwisp_utils.admin_theme', - # all-auth - 'django.contrib.sites', - 'openwisp_users.accounts', - 'allauth', - 'allauth.account', - 'allauth.socialaccount', - # (optional) openwisp_controller - required only if you are using the integration app - 'openwisp_controller.pki', - 'openwisp_controller.config', - 'reversion', - 'sortedm2m', - # network topology - # 'sample_network_topology' <-- uncomment and replace with your app-name here - # (optional) required only if you need to extend the integration app - # 'sample_integration_device' <-- uncomment and replace with your integration-app-name here - 'openwisp_users', - # admin - 'django.contrib.admin', - # rest framework - 'rest_framework', - ] - -For more information about how to work with django projects and django apps, -please refer to the `django documentation `_. - -2. Install ``openwisp-network-topology`` -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Install (and add to the requirement of your project) openwisp-network-topology:: - - pip install openwisp-network-topology - -3. Add ``EXTENDED_APPS`` -^^^^^^^^^^^^^^^^^^^^^^^^ - -Add the following to your ``settings.py``: - -.. code-block:: python - - EXTENDED_APPS = ('openwisp_network_topology',) - - -4. Add ``openwisp_utils.staticfiles.DependencyFinder`` -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Add ``openwisp_utils.staticfiles.DependencyFinder`` to -``STATICFILES_FINDERS`` in your ``settings.py``: - -.. code-block:: python - - STATICFILES_FINDERS = [ - 'django.contrib.staticfiles.finders.FileSystemFinder', - 'django.contrib.staticfiles.finders.AppDirectoriesFinder', - 'openwisp_utils.staticfiles.DependencyFinder', - ] - -5. Add ``openwisp_utils.loaders.DependencyLoader`` -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Add ``openwisp_utils.loaders.DependencyLoader`` to ``TEMPLATES`` in your ``settings.py``: - -.. code-block:: python - - TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'OPTIONS': { - 'loaders': [ - 'django.template.loaders.filesystem.Loader', - 'django.template.loaders.app_directories.Loader', - 'openwisp_utils.loaders.DependencyLoader', - ], - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - ], - }, - } - ] - - -6. Inherit the AppConfig class -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Please refer to the following files in the sample app of the test project: - -- `sample_network_topology/__init__.py `_. -- `sample_network_topology/apps.py `_. - -For the integration with openwisp-controller, see: - -- `sample_integration_device/__init__.py `_. -- `sample_integration_device/apps.py `_. - -You have to replicate and adapt that code in your project. - -For more information regarding the concept of ``AppConfig`` please refer to -the `"Applications" section in the django documentation `_. - - -7. Create your custom models -^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Please refer to `sample_app models file `_ -use in the test project. - -You have to replicate and adapt that code in your project. - -**Note**: for doubts regarding how to use, extend or develop models please refer to -the `"Models" section in the django documentation `_. - - -8. Add swapper configurations -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Once you have created the models, add the following to your ``settings.py``: - -.. code-block:: python - - # Setting models for swapper module - TOPOLOGY_LINK_MODEL = 'sample_network_topology.Link' - TOPOLOGY_NODE_MODEL = 'sample_network_topology.Node' - TOPOLOGY_SNAPSHOT_MODEL = 'sample_network_topology.Snapshot' - 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. - -9. Create database migrations -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Create and apply database migrations:: - - ./manage.py makemigrations - ./manage.py migrate - -For more information, refer to the -`"Migrations" section in the django documentation `_. - -10. Create the admin -^^^^^^^^^^^^^^^^^^^^ - -Refer to the `admin.py file of the sample app `_. - -To introduce changes to the admin, you can do it in two main ways which are described below. - -**Note**: for more information regarding how the django admin works, or how it can be customized, -please refer to `"The django admin site" section in the django documentation `_. - -1. Monkey patching -################## - -If the changes you need to add are relatively small, you can resort to monkey patching. - -For example: - -.. code-block:: python - - from openwisp_network_topology.admin import TopologyAdmin, LinkAdmin, NodeAdmin - - # TopologyAdmin.list_display.insert(1, 'my_custom_field') <-- your custom change example - # LinkAdmin.list_display.insert(1, 'my_custom_field') <-- your custom change example - # NodeAdmin.list_display.insert(1, 'my_custom_field') <-- your custom change example - -2. Inheriting admin classes -########################### - -If you need to introduce significant changes and/or you don't want to resort to -monkey patching, you can proceed as follows: - -.. code-block:: python - - from django.contrib import admin - from swapper import load_model - - from openwisp_network_topology.admin import ( - TopologyAdmin as BaseTopologyAdmin, - LinkAdmin as BaseLinkAdmin, - NodeAdmin as BaseNodeAdmin - ) - - Node = load_model('topology', 'Node') - Link = load_model('topology', 'Link') - Topology = load_model('topology', 'Topology') - - admin.site.unregister(Topology) - admin.site.unregister(Link) - admin.site.unregister(Node) - - @admin.register(Topology, TopologyAdmin) - class TopologyAdmin(BaseTopologyAdmin): - # add your changes here - - @admin.register(Link, LinkAdmin) - class LinkAdmin(BaseLinkAdmin): - # add your changes here - - @admin.register(Node, NodeAdmin) - class NodeAdmin(BaseNodeAdmin): - # add your changes here - -11. Create root URL configuration -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Please read and replicate according to your project needs: - -.. code-block:: python - - The following can be used to register all the urls in your - ``urls.py``. - - # If you've extended visualizer views (discussed below). - # Import visualizer views & function to add it. - # from openwisp_network_topology.utils import get_visualizer_urls - # from .sample_network_topology.visualizer import views - - urlpatterns = [ - # If you've extended visualizer views (discussed below). - # Add visualizer views in urls.py - # path('topology/', include(get_visualizer_urls(views))), - path('', include('openwisp_network_topology.urls')), - path('admin/', admin.site.urls), - ] - -For more information about URL configuration in django, please refer to the -`"URL dispatcher" section in the django documentation `_. - -12. Setup API urls -^^^^^^^^^^^^^^^^^^ - -You need to create a file ``api/urls.py`` (the name & path of the file must match) -inside your app, which contains the following: - -.. code-block:: python - - from openwisp_network_topology.api import views - # When you want to modify views, please change views location - # from . import views - from openwisp_network_topology.utils import get_api_urls - - urlpatterns = get_api_urls(views) - -13. Extending management commands -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -To extend the management commands, create `sample_network_topology/management/commands` directory and -two files in it: - -- `save_snapshot.py `_ -- `update_topology.py `_ - -14. Import the automated tests -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -When developing a custom application based on this module, it's a good -idea to import and run the base tests too, so that you can be sure the changes -you're introducing are not breaking some of the existing features of *openwisp-network-topology*. - -Refer to the `tests.py file of the sample app `_. - -In case you need to add breaking changes, you can overwrite the tests defined -in the base classes to test your own behavior. - -For testing you also need to extend the fixtures, you can copy the -file ``openwisp_network_topology/fixtures/test_users.json`` in your sample app's -``fixtures/`` directory. - -Now, you can then run tests with:: - - # the --parallel flag is optional - ./manage.py test --parallel sample_network_topology - -Substitute ``sample_network_topology`` with the name you chose in step 1. - -For more information about automated tests in django, please refer to -`"Testing in Django" `_. - -Other base classes that can be inherited and extended -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -The following steps are not required and are intended for more advanced customization. - -1. Extending API views -###################### - -Extending the views is only required when you want to make changes in the -behaviour of the API. -Please refer to `sample_network_topology/api/views.py -`_ -and replicate it in your application. - -If you extend these views, remember to use these views in the -``api/urls.py``. +OpenWISP Network Topology is a network topology collector and visualizer +web application and API, it allows to collect network topology data from +different networking software (dynamic mesh routing protocols, OpenVPN), +store it, visualize it, edit its details, it also provides hooks (a.k.a +`Django signals `_) +to execute code when the status of a link changes. -2. Extending the Visualizer views -################################# +For a complete overview of features, refer to the `Network Topology: +Features `_ +section of the OpenWISP documentation. -Similar to API views, visualizer views are only required to be extended -when you want to make changes in the Visualizer. -Please refer to `sample_network_topology/visualizer/views.py -`_ -and replicate it in your application. +Documentation +------------- -If you extend these views, remember to use these views in the ``urls.py``. +- `Developer documentation + `_ +- `User documentation `_ Contributing ------------ -Please refer to the `OpenWISP contributing guidelines `_. +Please refer to the `OpenWISP contributing guidelines +`_. Changelog --------- -See `CHANGES `_. +See `CHANGES +`_. License ------- -See `LICENSE `_. +See `LICENSE +`_. This projects bundles third-party javascript libraries in its source code: diff --git a/docs/developer/extending.rst b/docs/developer/extending.rst new file mode 100644 index 00000000..9ff9940a --- /dev/null +++ b/docs/developer/extending.rst @@ -0,0 +1,412 @@ +Extending OpenWISP Network Topology +=================================== + +.. include:: ../partials/developer-docs.rst + +One of the core values of the OpenWISP project is :ref:`Software +Reusability `, for this reason +*openwisp-network-topology* provides a set of base classes which can be +imported, extended and reused to create derivative apps. + +In order to implement your custom version of *openwisp-network-topology*, +you need to perform the steps described in this section. + +When in doubt, the code in the `test project +`_ +and the `sample app +`_ +will serve you as source of truth: just replicate and adapt that code to +get a basic derivative of *openwisp-network-topology* working. + +.. important:: + + If you plan on using a customized version of this module, we suggest + to start with it since the beginning, because migrating your data from + the default module to your extended version may be time consuming. + +.. contents:: **Table of Contents**: + :depth: 2 + :local: + +1. Initialize your Custom Module +-------------------------------- + +The first thing you need to do is to create a new django app which will +contain your custom version of *openwisp-network-topology*. + +A django app is nothing more than a `python package +`_ (a directory +of python scripts), in the following examples we'll call this django app +``sample_network_topology``, but you can name it how you want: + +.. code-block:: + + django-admin startapp sample_network_topology + +If you use the integration with openwisp-controller, you may want to +extend also the integration app if you need: + +.. code-block:: + + django-admin startapp sample_integration_device + +Keep in mind that the command mentioned above must be called from a +directory which is available in your `PYTHON_PATH +`_ so that +you can then import the result into your project. + +Now you need to add ``sample_network_topology`` to ``INSTALLED_APPS`` in +your ``settings.py``, ensuring also that ``openwisp_network_topology`` has +been removed: + +.. code-block:: python + + INSTALLED_APPS = [ + # ... other apps ... + "openwisp_utils.admin_theme", + # all-auth + "django.contrib.sites", + "openwisp_users.accounts", + "allauth", + "allauth.account", + "allauth.socialaccount", + # (optional) openwisp_controller - required only if you are using the integration app + "openwisp_controller.pki", + "openwisp_controller.config", + "reversion", + "sortedm2m", + # network topology + # 'sample_network_topology' <-- uncomment and replace with your app-name here + # (optional) required only if you need to extend the integration app + # 'sample_integration_device' <-- uncomment and replace with your integration-app-name here + "openwisp_users", + # admin + "django.contrib.admin", + # rest framework + "rest_framework", + ] + +For more information about how to work with django projects and django +apps, please refer to the `django documentation +`_. + +2. Install ``openwisp-network-topology`` +---------------------------------------- + +Install (and add to the requirement of your project) +openwisp-network-topology: + +.. code-block:: + + pip install openwisp-network-topology + +3. Add ``EXTENDED_APPS`` +------------------------ + +Add the following to your ``settings.py``: + +.. code-block:: python + + EXTENDED_APPS = ("openwisp_network_topology",) + +4. Add ``openwisp_utils.staticfiles.DependencyFinder`` +------------------------------------------------------ + +Add ``openwisp_utils.staticfiles.DependencyFinder`` to +``STATICFILES_FINDERS`` in your ``settings.py``: + +.. code-block:: python + + STATICFILES_FINDERS = [ + "django.contrib.staticfiles.finders.FileSystemFinder", + "django.contrib.staticfiles.finders.AppDirectoriesFinder", + "openwisp_utils.staticfiles.DependencyFinder", + ] + +5. Add ``openwisp_utils.loaders.DependencyLoader`` +-------------------------------------------------- + +Add ``openwisp_utils.loaders.DependencyLoader`` to ``TEMPLATES`` in your +``settings.py``: + +.. code-block:: python + + TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "OPTIONS": { + "loaders": [ + "django.template.loaders.filesystem.Loader", + "django.template.loaders.app_directories.Loader", + "openwisp_utils.loaders.DependencyLoader", + ], + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + } + ] + +6. Inherit the AppConfig Class +------------------------------ + +Please refer to the following files in the sample app of the test project: + +- `sample_network_topology/__init__.py + `_. +- `sample_network_topology/apps.py + `_. + +For the integration with openwisp-controller, see: + +- `sample_integration_device/__init__.py + `_. +- `sample_integration_device/apps.py + `_. + +You have to replicate and adapt that code in your project. + +For more information regarding the concept of ``AppConfig`` please refer +to the `"Applications" section in the django documentation +`_. + +7. Create your Custom Models +---------------------------- + +Please refer to `sample_app models file +`_ +use in the test project. + +You have to replicate and adapt that code in your project. + +**Note**: for doubts regarding how to use, extend or develop models please +refer to the `"Models" section in the django documentation +`_. + +8. Add Swapper Configurations +----------------------------- + +Once you have created the models, add the following to your +``settings.py``: + +.. code-block:: python + + # Setting models for swapper module + TOPOLOGY_LINK_MODEL = "sample_network_topology.Link" + TOPOLOGY_NODE_MODEL = "sample_network_topology.Node" + TOPOLOGY_SNAPSHOT_MODEL = "sample_network_topology.Snapshot" + 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. + +9. Create Database Migrations +----------------------------- + +Create and apply database migrations: + +.. code-block:: + + ./manage.py makemigrations + ./manage.py migrate + +For more information, refer to the `"Migrations" section in the django +documentation +`_. + +10. Create the Admin +-------------------- + +Refer to the `admin.py file of the sample app +`_. + +To introduce changes to the admin, you can do it in two main ways which +are described below. + +**Note**: for more information regarding how the django admin works, or +how it can be customized, please refer to `"The django admin site" section +in the django documentation +`_. + +1. Monkey Patching +~~~~~~~~~~~~~~~~~~ + +If the changes you need to add are relatively small, you can resort to +monkey patching. + +For example: + +.. code-block:: python + + from openwisp_network_topology.admin import ( + TopologyAdmin, + LinkAdmin, + NodeAdmin, + ) + + # TopologyAdmin.list_display.insert(1, 'my_custom_field') <-- your custom change example + # LinkAdmin.list_display.insert(1, 'my_custom_field') <-- your custom change example + # NodeAdmin.list_display.insert(1, 'my_custom_field') <-- your custom change example + +2. Inheriting Admin Classes +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you need to introduce significant changes and/or you don't want to +resort to monkey patching, you can proceed as follows: + +.. code-block:: python + + from django.contrib import admin + from swapper import load_model + + from openwisp_network_topology.admin import ( + TopologyAdmin as BaseTopologyAdmin, + LinkAdmin as BaseLinkAdmin, + NodeAdmin as BaseNodeAdmin, + ) + + Node = load_model("topology", "Node") + Link = load_model("topology", "Link") + Topology = load_model("topology", "Topology") + + admin.site.unregister(Topology) + admin.site.unregister(Link) + admin.site.unregister(Node) + + + @admin.register(Topology, TopologyAdmin) + class TopologyAdmin(BaseTopologyAdmin): + # add your changes here + pass + + + @admin.register(Link, LinkAdmin) + class LinkAdmin(BaseLinkAdmin): + # add your changes here + pass + + + @admin.register(Node, NodeAdmin) + class NodeAdmin(BaseNodeAdmin): + # add your changes here + pass + +11. Create Root URL Configuration +--------------------------------- + +The following can be used to register all the urls in your + ``urls.py``. + +Please read and replicate according to your project needs: + +.. code-block:: python + + # If you've extended visualizer views (discussed below). + # Import visualizer views & function to add it. + # from openwisp_network_topology.utils import get_visualizer_urls + # from .sample_network_topology.visualizer import views + + urlpatterns = [ + # If you've extended visualizer views (discussed below). + # Add visualizer views in urls.py + # path('topology/', include(get_visualizer_urls(views))), + path("", include("openwisp_network_topology.urls")), + path("admin/", admin.site.urls), + ] + +For more information about URL configuration in django, please refer to +the `"URL dispatcher" section in the django documentation +`_. + +12. Setup API URLs +------------------ + +You need to create a file ``api/urls.py`` (the name & path of the file +must match) inside your app, which contains the following: + +.. code-block:: python + + from openwisp_network_topology.api import views + + # When you want to modify views, please change views location + # from . import views + from openwisp_network_topology.utils import get_api_urls + + urlpatterns = get_api_urls(views) + +13. Extending Management Commands +--------------------------------- + +To extend the management commands, create +`sample_network_topology/management/commands` directory and two files in +it: + +- `save_snapshot.py + `_ +- `update_topology.py + `_ + +14. Import the Automated Tests +------------------------------ + +When developing a custom application based on this module, it's a good +idea to import and run the base tests too, so that you can be sure the +changes you're introducing are not breaking some of the existing features +of *openwisp-network-topology*. + +Refer to the `tests.py file of the sample app +`_. + +In case you need to add breaking changes, you can overwrite the tests +defined in the base classes to test your own behavior. + +For testing you also need to extend the fixtures, you can copy the file +``openwisp_network_topology/fixtures/test_users.json`` in your sample +app's ``fixtures/`` directory. + +Now, you can then run tests with: + +.. code-block:: + + # the --parallel flag is optional + ./manage.py test --parallel sample_network_topology + +Substitute ``sample_network_topology`` with the name you chose in step 1. + +For more information about automated tests in django, please refer to +`"Testing in Django" +`_. + +Other Base Classes that can be Inherited and Extended +----------------------------------------------------- + +The following steps are not required and are intended for more advanced +customization. + +1. Extending API Views +~~~~~~~~~~~~~~~~~~~~~~ + +Extending the views is only required when you want to make changes in the +behaviour of the API. Please refer to +`sample_network_topology/api/views.py +`_ +and replicate it in your application. + +If you extend these views, remember to use these views in the +``api/urls.py``. + +2. Extending the Visualizer Views +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Similar to API views, visualizer views are only required to be extended +when you want to make changes in the Visualizer. Please refer to +`sample_network_topology/visualizer/views.py +`_ +and replicate it in your application. + +If you extend these views, remember to use these views in the ``urls.py``. diff --git a/docs/developer/index.rst b/docs/developer/index.rst new file mode 100644 index 00000000..df78d2b9 --- /dev/null +++ b/docs/developer/index.rst @@ -0,0 +1,16 @@ +Developer Docs +============== + +.. include:: ../partials/developer-docs.rst + +.. toctree:: + :maxdepth: 2 + + ./installation.rst + ./overriding-visualizer-templates.rst + ./extending.rst + +Other useful resources: + + - :doc:`../user/rest-api` + - :doc:`../user/settings` diff --git a/docs/developer/installation.rst b/docs/developer/installation.rst new file mode 100644 index 00000000..c695b3d0 --- /dev/null +++ b/docs/developer/installation.rst @@ -0,0 +1,119 @@ +Installation Instructions +========================= + +.. include:: ../partials/developer-docs.rst + +.. contents:: **Table of contents**: + :depth: 2 + :local: + +Installing for Development +-------------------------- + +Install the system dependencies: + +.. code-block:: shell + + 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-network-topology and openwisp-monitoring. + sudo apt install libspatialite-dev libsqlite3-mod-spatialite + +Fork and clone the forked repository: + +.. code-block:: shell + + git clone git://github.com//openwisp-network-topology + +Navigate into the cloned repository: + +.. code-block:: shell + + cd openwisp-network-topology/ + +Start InfluxDB and Redis using Docker (required by the test project to run +tests for :ref:`WiFi Mesh Integration +`): + +.. code-block:: shell + + docker-compose up -d influxdb redis + +Setup and activate a virtual-environment (we'll be using `virtualenv +`_): + +.. code-block:: shell + + python -m virtualenv env + source env/bin/activate + +Make sure that your base python packages are up to date before moving to +the next step: + +.. code-block:: shell + + pip install -U pip wheel setuptools + +Install development dependencies: + +.. code-block:: shell + + pip install -e . + pip install -r requirements-test.txt + +Create database: + +.. code-block:: shell + + cd tests/ + ./manage.py migrate + ./manage.py createsuperuser + +You can access the admin interface at ``http://127.0.0.1:8000/admin/``. + +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: + +.. code-block:: shell + + ./run-qa-checks + +Alternative Sources +------------------- + +Pypi +~~~~ + +To install the latest stable version from pypi: + +.. code-block:: shell + + pip install openwisp-network-topology + +Github +~~~~~~ + +To install the latest development version tarball via HTTPs: + +.. code-block:: shell + + pip install https://github.com/openwisp/openwisp-network-topology/tarball/master + +Alternatively you can use the git protocol: + +.. code-block:: shell + + pip install -e git+git://github.com/openwisp/openwisp-network-topology#egg=openwisp_network-topology diff --git a/docs/developer/overriding-visualizer-templates.rst b/docs/developer/overriding-visualizer-templates.rst new file mode 100644 index 00000000..c5a9300c --- /dev/null +++ b/docs/developer/overriding-visualizer-templates.rst @@ -0,0 +1,53 @@ +Overriding Visualizer Templates +=============================== + +.. include:: ../partials/developer-docs.rst + +Follow these steps to override and customize the visualizer's default +templates: + +- create a directory in your django project and put its full path in + ``TEMPLATES['DIRS']``, which can be found in the django ``settings.py`` + file +- create a sub directory named ``netjsongraph`` and add all the templates + which shall override the default ``netjsongraph/*`` templates +- create a template file with the same name of the template file you want + to override + +More information about the syntax used in django templates can be found in +the `django templates documentation +`_. + +Example: Overriding the `` diff --git a/docs/images/architecture-v2-openwisp-network-topology.png b/docs/images/architecture-v2-openwisp-network-topology.png new file mode 100644 index 0000000000000000000000000000000000000000..8e7b82a68b330e4f744381d88f42842ddf9ebe62 GIT binary patch literal 398547 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