diff --git a/README.rst b/README.rst
index 6e8804730..5b8f80b68 100755
--- a/README.rst
+++ b/README.rst
@@ -593,6 +593,251 @@ For example, if we want to change the verbose name to "Hotspot", we could write:
OPENWISP_CONTROLLER_DEVICE_VERBOSE_NAME = ('Hotspot', 'Hotspots')
+``OPENWISP_CONTROLLER_API``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
++--------------+-----------+
+| **type**: | ``bool`` |
++--------------+-----------+
+| **default**: | ``True`` |
++--------------+-----------+
+
+Indicates whether the API for Openwisp Controller is enabled or not.
+To disable the API by default add `OPENWISP_CONTROLLER_API = False` in `settings.py` file.
+
+REST API
+--------
+
+Live documentation
+~~~~~~~~~~~~~~~~~~
+
+.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/master/docs/live-docu-api.png
+
+A general live API documentation (following the OpenAPI specification) at ``/api/v1/docs/``.
+
+Browsable web interface
+~~~~~~~~~~~~~~~~~~~~~~~
+
+.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/master/docs/browsable-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.
+
+Authentication
+~~~~~~~~~~~~~~
+
+See openwisp-users: `authenticating with the user token
+`_.
+
+When browsing the API via the `Live documentation <#live-documentation>`_
+or the `Browsable web page <#browsable-web-interface>`_, you can also use
+the session authentication by logging in the django admin.
+
+Pagination
+~~~~~~~~~~
+
+All *list* endpoints support the ``page_size`` parameter that allows paginating
+the results in conjunction with the ``page`` parameter.
+
+.. code-block:: text
+
+ GET /api/v1/controller/template/?page_size=10
+ GET /api/v1/controller/template/?page_size=10&page=2
+
+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 devices
+^^^^^^^^^^^^
+
+.. code-block:: text
+
+ GET /api/v1/controller/device/
+
+Create device
+^^^^^^^^^^^^^
+
+.. code-block:: text
+
+ POST /api/v1/controller/device/
+
+Get device detail
+^^^^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+ GET /api/v1/controller/device/{pk}/
+
+Download device configuration
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+ GET /api/v1/controller/device/{pk}/configuration/
+
+The above endpoint triggers the download of a ``tar.gz`` file containing the generated configuration for that specific device.
+
+Change details of device
+^^^^^^^^^^^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+ PUT /api/v1/controller/device/{pk}/
+
+Patch details of device
+^^^^^^^^^^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+ PATCH /api/v1/controller/device/{pk}/
+
+**Note**: To assign, unassign, and change the order of the assigned templates add,
+remove, and change the order of the ``{pk}`` of the templates under the ``config`` field in the JSON response respectively.
+Moreover, you can also select and unselect templates in the HTML Form of the Browsable API.
+
+The required template(s) from the organization(s) of the device will added automatically
+to the ``config`` and cannot be removed.
+
+**Example usage**: For assigning template(s) add the/their {pk} to the config of a device,
+
+.. code-block:: shell
+
+ echo '{"config":{"templates": ["4791fa4c-2cef-4f42-8bb4-c86018d71bd3"]}}' | \
+ http PATCH http://127.0.0.1:8000/api/v1/controller/device/76b7d9cc-4ffd-4a43-b1b0-8f8befd1a7c0/ \
+ "Authorization: Bearer 9b5e40da02d107cfdb9d6b69b26dc00332ec2fbc"
+
+**Example usage**: For removing assigned templates, simply remove the/their {pk} from the config of a device,
+
+.. code-block:: shell
+
+ echo '{"config":{"templates": []}}' | \
+ http PATCH http://127.0.0.1:8000/api/v1/controller/device/76b7d9cc-4ffd-4a43-b1b0-8f8befd1a7c0/ \
+ "Authorization: Bearer 9b5e40da02d107cfdb9d6b69b26dc00332ec2fbc"
+
+**Example usage**: For reordering the templates simply change their order from the config of a device,
+
+.. code-block:: shell
+
+ echo '{"config":{"templates": ["c5bbc697-170e-44bc-8eb7-b944b55ee88f","4791fa4c-2cef-4f42-8bb4-c86018d71bd3"]}}' | \
+ http PATCH http://127.0.0.1:8000/api/v1/controller/device/76b7d9cc-4ffd-4a43-b1b0-8f8befd1a7c0/ \
+ "Authorization: Bearer 9b5e40da02d107cfdb9d6b69b26dc00332ec2fbc"
+
+Delete device
+^^^^^^^^^^^^^
+
+.. code-block:: text
+
+ DELETE /api/v1/controller/device/{pk}/
+
+List templates
+^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+ GET /api/v1/controller/template/
+
+Create template
+^^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+ POST /api/v1/controller/template/
+
+Get template detail
+^^^^^^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+ GET /api/v1/controller/template/{pk}/
+
+Download template configuration
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+ GET /api/v1/controller/template/{pk}/configuration/
+
+The above endpoint triggers the download of a ``tar.gz`` file containing the generated configuration for that specific template.
+
+Change details of template
+^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+ PUT /api/v1/controller/template/{pk}/
+
+Patch details of template
+^^^^^^^^^^^^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+ PATCH /api/v1/controller/template/{pk}/
+
+Delete template
+^^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+ DELETE /api/v1/controller/template/{pk}/
+
+List VPNs
+^^^^^^^^^
+
+.. code-block:: text
+
+ GET /api/v1/controller/vpn/
+
+Create VPN
+^^^^^^^^^^
+
+.. code-block:: text
+
+ POST /api/v1/controller/vpn/
+
+Get VPN detail
+^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+ GET /api/v1/controller/vpn/{pk}/
+
+Download VPN configuration
+^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+ GET /api/v1/controller/vpn/{pk}/configuration/
+
+The above endpoint triggers the download of a ``tar.gz`` file containing the generated configuration for that specific VPN.
+
+Change details of VPN
+^^^^^^^^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+ PUT /api/v1/controller/vpn/{pk}/
+
+Patch details of VPN
+^^^^^^^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+ PATCH /api/v1/controller/vpn/{pk}/
+
+Delete VPN
+^^^^^^^^^^
+
+.. code-block:: text
+
+ DELETE /api/v1/controller/vpn/{pk}/
+
Default Alerts / Notifications
------------------------------
diff --git a/docs/browsable-api-ui.png b/docs/browsable-api-ui.png
new file mode 100644
index 000000000..7a58bf98d
Binary files /dev/null and b/docs/browsable-api-ui.png differ
diff --git a/docs/live-docu-api.png b/docs/live-docu-api.png
new file mode 100644
index 000000000..dde1c0d05
Binary files /dev/null and b/docs/live-docu-api.png differ
diff --git a/openwisp_controller/config/api/__init__.py b/openwisp_controller/config/api/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/openwisp_controller/config/api/serializers.py b/openwisp_controller/config/api/serializers.py
new file mode 100644
index 000000000..60a0359e8
--- /dev/null
+++ b/openwisp_controller/config/api/serializers.py
@@ -0,0 +1,258 @@
+from django.db import transaction
+from django.db.models import Q
+from django.utils.translation import ugettext_lazy as _
+from rest_framework import serializers
+from swapper import load_model
+
+from openwisp_users.api.mixins import FilterSerializerByOrgManaged
+from openwisp_utils.api.serializers import ValidatedModelSerializer
+
+Template = load_model('config', 'Template')
+Vpn = load_model('config', 'Vpn')
+Device = load_model('config', 'Device')
+Config = load_model('config', 'Config')
+Organization = load_model('openwisp_users', 'Organization')
+
+
+class BaseMeta:
+ read_only_fields = ['created', 'modified']
+
+
+class BaseSerializer(FilterSerializerByOrgManaged, ValidatedModelSerializer):
+ pass
+
+
+class TemplateSerializer(BaseSerializer):
+ config = serializers.JSONField(initial={}, required=False)
+ tags = serializers.StringRelatedField(many=True, read_only=True)
+ default_values = serializers.JSONField(required=False, initial={})
+ include_shared = True
+
+ class Meta(BaseMeta):
+ model = Template
+ fields = [
+ 'id',
+ 'name',
+ 'organization',
+ 'type',
+ 'backend',
+ 'vpn',
+ 'tags',
+ 'default',
+ 'required',
+ 'default_values',
+ 'config',
+ 'created',
+ 'modified',
+ ]
+
+ def validate_vpn(self, value):
+ """
+ Ensure that VPN can't be added when
+ template `Type` is set to `Generic`.
+ """
+ if self.initial_data.get('type') == 'generic' and value is not None:
+ raise serializers.ValidationError(
+ _("To select a VPN, set the template type to 'VPN-client'")
+ )
+ return value
+
+ def validate_config(self, value):
+ """
+ Display appropriate field name.
+ """
+ if self.initial_data.get('type') == 'generic' and value == {}:
+ raise serializers.ValidationError(
+ _('The configuration field cannot be empty.')
+ )
+ return value
+
+
+class VpnSerializer(BaseSerializer):
+ config = serializers.JSONField(initial={})
+ include_shared = True
+
+ class Meta(BaseMeta):
+ model = Vpn
+ fields = [
+ 'id',
+ 'name',
+ 'host',
+ 'organization',
+ 'key',
+ 'ca',
+ 'cert',
+ 'backend',
+ 'notes',
+ 'dh',
+ 'config',
+ 'created',
+ 'modified',
+ ]
+
+
+class FilterTemplatesByOrganization(serializers.PrimaryKeyRelatedField):
+ def get_queryset(self):
+ user = self.context['request'].user
+ if user.is_superuser:
+ queryset = Template.objects.all()
+ else:
+ queryset = Template.objects.filter(
+ Q(organization__in=user.organizations_managed)
+ | Q(organization__isnull=True)
+ )
+ return queryset
+
+
+class BaseConfigSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = Config
+ fields = ['status', 'backend', 'templates', 'context', 'config']
+ extra_kwargs = {'status': {'read_only': True}}
+
+
+class DeviceListConfigSerializer(BaseConfigSerializer):
+ config = serializers.JSONField(
+ initial={}, help_text=_('Configuration in NetJSON format'), write_only=True
+ )
+ context = serializers.JSONField(
+ initial={},
+ help_text=_('Configuration variables in JSON format'),
+ write_only=True,
+ )
+ templates = FilterTemplatesByOrganization(many=True, write_only=True)
+
+
+class DeviceListSerializer(FilterSerializerByOrgManaged, serializers.ModelSerializer):
+ config = DeviceListConfigSerializer(required=False)
+
+ class Meta(BaseMeta):
+ model = Device
+ fields = [
+ 'id',
+ 'name',
+ 'organization',
+ 'mac_address',
+ 'key',
+ 'last_ip',
+ 'management_ip',
+ 'model',
+ 'os',
+ 'system',
+ 'notes',
+ 'config',
+ 'created',
+ 'modified',
+ ]
+ extra_kwargs = {
+ 'last_ip': {'allow_blank': True},
+ 'management_ip': {'allow_blank': True},
+ }
+
+ def create(self, validated_data):
+ config_data = None
+ if validated_data.get('config'):
+ config_data = validated_data.pop('config')
+ config_templates = [
+ template.pk for template in config_data.pop('templates')
+ ]
+
+ with transaction.atomic():
+ device = Device.objects.create(**validated_data)
+ if config_data:
+ config = Config.objects.create(device=device, **config_data)
+ config.templates.add(*config_templates)
+ return device
+
+
+class DeviceDetailConfigSerializer(BaseConfigSerializer):
+ config = serializers.JSONField(
+ initial={}, help_text=_('Configuration in NetJSON format')
+ )
+ context = serializers.JSONField(
+ initial={}, help_text=_('Configuration variables in JSON format')
+ )
+ templates = FilterTemplatesByOrganization(many=True)
+
+
+class DeviceDetailSerializer(BaseSerializer):
+ config = DeviceDetailConfigSerializer(allow_null=True)
+
+ class Meta(BaseMeta):
+ model = Device
+ fields = [
+ 'id',
+ 'name',
+ 'organization',
+ 'mac_address',
+ 'key',
+ 'last_ip',
+ 'management_ip',
+ 'model',
+ 'os',
+ 'system',
+ 'notes',
+ 'config',
+ 'created',
+ 'modified',
+ ]
+
+ def update(self, instance, validated_data):
+ config_data = None
+
+ if self.initial_data.get('config.backend') and instance._has_config() is False:
+ config_data = dict(validated_data.pop('config'))
+ config_templates = [
+ template.pk for template in config_data.pop('templates')
+ ]
+ with transaction.atomic():
+ config = Config.objects.create(device=instance, **config_data)
+ config.templates.add(*config_templates)
+ config.full_clean()
+ config.save()
+ return super().update(instance, validated_data)
+
+ if validated_data.get('config'):
+ config_data = validated_data.pop('config')
+ instance.config.backend = config_data.get(
+ 'backend', instance.config.backend
+ )
+ instance.config.context = config_data.get(
+ 'context', instance.config.context
+ )
+ instance.config.config = config_data.get('config', instance.config.config)
+
+ if 'templates' in config_data:
+ if config_data.get('templates'):
+ new_config_templates = [
+ template.pk for template in config_data.get('templates')
+ ]
+ old_config_templates = [
+ template
+ for template in instance.config.templates.values_list(
+ 'pk', flat=True
+ )
+ ]
+ if new_config_templates != old_config_templates:
+ with transaction.atomic():
+ vpn_list = instance.config.templates.filter(
+ type='vpn'
+ ).values_list('vpn')
+ if vpn_list:
+ instance.config.vpnclient_set.exclude(
+ vpn__in=vpn_list
+ ).delete()
+ instance.config.templates.clear()
+ instance.config.templates.add(*new_config_templates)
+ else:
+ vpn_list = instance.config.templates.filter(type='vpn').values_list(
+ 'vpn'
+ )
+ if vpn_list:
+ instance.config.vpnclient_set.exclude(vpn__in=vpn_list).delete()
+ instance.config.templates.clear()
+ instance.config.templates.add(*[])
+
+ instance.config.full_clean()
+ instance.config.save()
+ return super().update(instance, validated_data)
diff --git a/openwisp_controller/config/api/urls.py b/openwisp_controller/config/api/urls.py
new file mode 100644
index 000000000..3519c1e9c
--- /dev/null
+++ b/openwisp_controller/config/api/urls.py
@@ -0,0 +1,55 @@
+from django.conf import settings
+from django.urls import path
+
+from . import views as api_views
+
+app_name = 'openwisp_controller'
+
+
+def get_api_urls(api_views):
+ """
+ returns:: all the API urls of the config app
+ """
+ if getattr(settings, 'OPENWISP_CONTROLLER_API', True):
+ return [
+ path(
+ 'controller/template/',
+ api_views.template_list,
+ name='api_template_list',
+ ),
+ path(
+ 'controller/template//',
+ api_views.template_detail,
+ name='api_template_detail',
+ ),
+ path(
+ 'controller/template//configuration/',
+ api_views.download_template_config,
+ name='api_download_template_config',
+ ),
+ path('controller/vpn/', api_views.vpn_list, name='api_vpn_list',),
+ path(
+ 'controller/vpn//', api_views.vpn_detail, name='api_vpn_detail',
+ ),
+ path(
+ 'controller/vpn//configuration/',
+ api_views.download_vpn_config,
+ name='api_download_vpn_config',
+ ),
+ path('controller/device/', api_views.device_list, name='api_device_list',),
+ path(
+ 'controller/device//',
+ api_views.device_detail,
+ name='api_device_detail',
+ ),
+ path(
+ 'controller/device//configuration/',
+ api_views.download_device_config,
+ name='api_download_device_config',
+ ),
+ ]
+ else:
+ return []
+
+
+urlpatterns = get_api_urls(api_views)
diff --git a/openwisp_controller/config/api/views.py b/openwisp_controller/config/api/views.py
new file mode 100644
index 000000000..055b1b2f9
--- /dev/null
+++ b/openwisp_controller/config/api/views.py
@@ -0,0 +1,120 @@
+from rest_framework import pagination
+from rest_framework.authentication import SessionAuthentication
+from rest_framework.generics import (
+ ListCreateAPIView,
+ RetrieveAPIView,
+ RetrieveUpdateDestroyAPIView,
+)
+from rest_framework.permissions import DjangoModelPermissions, IsAuthenticated
+from swapper import load_model
+
+from openwisp_users.api.authentication import BearerAuthentication
+from openwisp_users.api.mixins import FilterByOrganizationManaged
+
+from ..admin import BaseConfigAdmin
+from .serializers import (
+ DeviceDetailSerializer,
+ DeviceListSerializer,
+ TemplateSerializer,
+ VpnSerializer,
+)
+
+Template = load_model('config', 'Template')
+Vpn = load_model('config', 'Vpn')
+Device = load_model('config', 'Device')
+Config = load_model('config', 'Config')
+
+
+class ListViewPagination(pagination.PageNumberPagination):
+ page_size = 10
+ page_size_query_param = 'page_size'
+ max_page_size = 100
+
+
+class ProtectedAPIMixin(FilterByOrganizationManaged):
+ authentication_classes = [BearerAuthentication, SessionAuthentication]
+ permission_classes = [
+ IsAuthenticated,
+ DjangoModelPermissions,
+ ]
+
+
+class TemplateListCreateView(ProtectedAPIMixin, ListCreateAPIView):
+ serializer_class = TemplateSerializer
+ queryset = Template.objects.order_by('-created')
+ pagination_class = ListViewPagination
+
+
+class TemplateDetailView(ProtectedAPIMixin, RetrieveUpdateDestroyAPIView):
+ serializer_class = TemplateSerializer
+ queryset = Template.objects.all()
+
+
+class DownloadTemplateconfiguration(ProtectedAPIMixin, RetrieveAPIView):
+ serializer_class = TemplateSerializer
+ queryset = Template.objects.none()
+ model = Template
+
+ def retrieve(self, request, *args, **kwargs):
+ return BaseConfigAdmin.download_view(self, request, pk=kwargs['pk'])
+
+
+class VpnListCreateView(ProtectedAPIMixin, ListCreateAPIView):
+ serializer_class = VpnSerializer
+ queryset = Vpn.objects.order_by('-created')
+ pagination_class = ListViewPagination
+
+
+class VpnDetailView(ProtectedAPIMixin, RetrieveUpdateDestroyAPIView):
+ serializer_class = VpnSerializer
+ queryset = Vpn.objects.all()
+
+
+class DownloadVpnView(ProtectedAPIMixin, RetrieveAPIView):
+ serializer_class = VpnSerializer
+ queryset = Vpn.objects.none()
+ model = Vpn
+
+ def retrieve(self, request, *args, **kwargs):
+ return BaseConfigAdmin.download_view(self, request, pk=kwargs['pk'])
+
+
+class DeviceListCreateView(ProtectedAPIMixin, ListCreateAPIView):
+ """
+ Templates: Templates flagged as required will be added automatically
+ to the `config` of a device and cannot be unassigned.
+ """
+
+ serializer_class = DeviceListSerializer
+ queryset = Device.objects.select_related('config').order_by('-created')
+ pagination_class = ListViewPagination
+
+
+class DeviceDetailView(ProtectedAPIMixin, RetrieveUpdateDestroyAPIView):
+ """
+ Templates: Templates flagged as _required_ will be added automatically
+ to the `config` of a device and cannot be unassigned.
+ """
+
+ serializer_class = DeviceDetailSerializer
+ queryset = Device.objects.select_related('config')
+
+
+class DownloadDeviceView(ProtectedAPIMixin, RetrieveAPIView):
+ serializer_class = DeviceListSerializer
+ queryset = Device.objects.none()
+ model = Device
+
+ def retrieve(self, request, *args, **kwargs):
+ return BaseConfigAdmin.download_view(self, request, pk=kwargs['pk'])
+
+
+template_list = TemplateListCreateView.as_view()
+template_detail = TemplateDetailView.as_view()
+download_template_config = DownloadTemplateconfiguration.as_view()
+vpn_list = VpnListCreateView.as_view()
+vpn_detail = VpnDetailView.as_view()
+download_vpn_config = DownloadVpnView.as_view()
+device_list = DeviceListCreateView.as_view()
+device_detail = DeviceDetailView.as_view()
+download_device_config = DownloadDeviceView().as_view()
diff --git a/openwisp_controller/config/tests/test_api.py b/openwisp_controller/config/tests/test_api.py
new file mode 100644
index 000000000..7d1c1c8a1
--- /dev/null
+++ b/openwisp_controller/config/tests/test_api.py
@@ -0,0 +1,512 @@
+from django.core.exceptions import ValidationError
+from django.test import TestCase
+from django.urls import reverse
+from swapper import load_model
+
+from openwisp_controller.tests.utils import TestAdminMixin
+from openwisp_users.tests.utils import TestOrganizationMixin
+
+from .utils import CreateConfigTemplateMixin, TestVpnX509Mixin
+
+Template = load_model('config', 'Template')
+Vpn = load_model('config', 'Vpn')
+Device = load_model('config', 'Device')
+
+
+class TestConfigApi(
+ TestAdminMixin,
+ TestOrganizationMixin,
+ CreateConfigTemplateMixin,
+ TestVpnX509Mixin,
+ TestCase,
+):
+ def setUp(self):
+ super().setUp()
+ self._login()
+
+ _get_template_data = {
+ 'name': 'test-template',
+ 'organization': None,
+ 'backend': 'netjsonconfig.OpenWrt',
+ 'config': {'interfaces': [{'name': 'eth0', 'type': 'ethernet'}]},
+ }
+
+ _get_vpn_data = {
+ 'name': 'vpn-test',
+ 'host': 'vpn.testing.com',
+ 'organization': None,
+ 'ca': None,
+ 'backend': 'openwisp_controller.vpn_backends.OpenVpn',
+ 'config': {
+ 'openvpn': [
+ {
+ 'ca': 'ca.pem',
+ 'cert': 'cert.pem',
+ 'dev': 'tap0',
+ 'dev_type': 'tap',
+ 'dh': 'dh.pem',
+ 'key': 'key.pem',
+ 'mode': 'server',
+ 'name': 'example-vpn',
+ 'proto': 'udp',
+ 'tls_server': True,
+ }
+ ]
+ },
+ }
+
+ _get_device_data = {
+ 'name': 'change-test-device',
+ 'organization': None,
+ 'mac_address': '00:11:22:33:44:55',
+ 'config': {
+ 'backend': 'netjsonconfig.OpenWrt',
+ 'status': 'modified',
+ 'templates': [],
+ 'context': '{}',
+ 'config': '{}',
+ },
+ }
+
+ def test_device_create_with_config_api(self):
+ self.assertEqual(Device.objects.count(), 0)
+ path = reverse('controller_config:api_device_list')
+ data = self._get_device_data.copy()
+ org = self._get_org()
+ data['organization'] = org.pk
+ r = self.client.post(path, data, content_type='application/json')
+ self.assertEqual(r.status_code, 201)
+ self.assertEqual(Device.objects.count(), 1)
+
+ def test_device_create_no_config_api(self):
+ self.assertEqual(Device.objects.count(), 0)
+ path = reverse('controller_config:api_device_list')
+ data = self._get_device_data.copy()
+ org = self._get_org()
+ data['organization'] = org.pk
+ data.pop('config')
+ r = self.client.post(path, data, content_type='application/json')
+ self.assertEqual(r.status_code, 201)
+ self.assertEqual(Device.objects.count(), 1)
+
+ def test_device_create_with_invalid_name_api(self):
+ path = reverse('controller_config:api_device_list')
+ data = self._get_device_data.copy()
+ org = self._get_org()
+ data.pop('config')
+ data['name'] = 'T E S T'
+ data['organization'] = org.pk
+ r = self.client.post(path, data, content_type='application/json')
+ self.assertEqual(r.status_code, 400)
+ self.assertIn('Must be either a valid hostname or mac address.', str(r.content))
+
+ # POST request should fail with validation error
+ def test_device_post_with_templates_of_different_org(self):
+ path = reverse('controller_config:api_device_list')
+ data = self._get_device_data.copy()
+ org_1 = self._get_org()
+ data['organization'] = org_1.pk
+ org_2 = self._create_org(name='test org2', slug='test-org2')
+ t1 = self._create_template(name='t1', organization=org_2)
+ data['config']['templates'] += [str(t1.pk)]
+ with self.assertRaises(ValidationError) as error:
+ self.client.post(path, data, content_type='application/json')
+ validation_msg = '''
+ The following templates are owned by
+ organizations which do not match the
+ organization of this configuration: t1
+ '''
+ self.assertTrue(' '.join(validation_msg.split()) in error.exception.message)
+
+ def test_device_list_api(self):
+ self._create_device()
+ path = reverse('controller_config:api_device_list')
+ with self.assertNumQueries(4):
+ r = self.client.get(path)
+ self.assertEqual(r.status_code, 200)
+
+ def test_device_filter_templates(self):
+ org1 = self._create_org(name='org1')
+ org2 = self._create_org(name='org2')
+ test_user = self._create_operator(organizations=[org1])
+ self.client.force_login(test_user)
+ self._create_template(name='t0', organization=None)
+ self._create_template(name='t1', organization=org1)
+ self._create_template(name='t11', organization=org1)
+ self._create_template(name='t2', organization=org2)
+ path = reverse('controller_config:api_device_list')
+ r = self.client.get(path, {'format': 'api'})
+ self.assertEqual(r.status_code, 200)
+ self.assertContains(r, 't0')
+ self.assertContains(r, 't1')
+ self.assertContains(r, 't11')
+ self.assertNotContains(r, 't2')
+
+ # Device detail having no config
+ def test_device_detail_api(self):
+ d1 = self._create_device()
+ path = reverse('controller_config:api_device_detail', args=[d1.pk])
+ with self.assertNumQueries(3):
+ r = self.client.get(path)
+ self.assertEqual(r.status_code, 200)
+ self.assertEqual(r.data['config'], None)
+
+ # Device detail having config
+ def test_device_detail_config_api(self):
+ d1 = self._create_device()
+ self._create_config(device=d1)
+ path = reverse('controller_config:api_device_detail', args=[d1.pk])
+ with self.assertNumQueries(4):
+ r = self.client.get(path)
+ self.assertEqual(r.status_code, 200)
+ self.assertNotEqual(r.data['config'], None)
+
+ def test_device_put_api(self):
+ d1 = self._create_device(name='test-device')
+ self._create_config(device=d1)
+ path = reverse('controller_config:api_device_detail', args=[d1.pk])
+ org = self._get_org()
+ data = {
+ 'name': 'change-test-device',
+ 'organization': org.pk,
+ 'mac_address': d1.mac_address,
+ 'config': {
+ 'backend': 'netjsonconfig.OpenWrt',
+ 'status': 'modified',
+ 'templates': [],
+ 'context': '{}',
+ 'config': '{}',
+ },
+ }
+
+ r = self.client.put(path, data, content_type='application/json')
+ self.assertEqual(r.status_code, 200)
+ self.assertEqual(r.data['name'], 'change-test-device')
+ self.assertEqual(r.data['organization'], org.pk)
+
+ # test device with VPN-client type template assigned to it
+ def test_device_with_vpn_client_template_assigned(self):
+ org1 = self._create_org(name='testorg')
+ v1 = self._create_vpn(name='vpn1org', organization=org1)
+ t1 = self._create_template(name='t1', organization=org1, type='vpn', vpn=v1)
+ t2 = self._create_template(name='t2', organization=org1)
+ d1 = self._create_device(name='d1', organization=org1)
+ self._create_config(device=d1)
+ path = reverse('controller_config:api_device_detail', args=[d1.pk])
+ self.assertEqual(d1.config.vpnclient_set.count(), 0)
+ data = {'config': {'templates': [str(t1.id), str(t2.id)]}}
+ self.client.patch(path, data, content_type='application/json')
+ self.assertEqual(d1.config.vpnclient_set.count(), 1)
+ data1 = {'config': {'templates': []}}
+ self.client.patch(path, data1, content_type='application/json')
+ self.assertEqual(d1.config.vpnclient_set.count(), 0)
+
+ def test_device_patch_with_templates_of_same_org(self):
+ org1 = self._create_org(name='testorg')
+ d1 = self._create_device(name='org1-config', organization=org1)
+ self._create_config(device=d1)
+ self.assertEqual(d1.config.templates.count(), 0)
+ path = reverse('controller_config:api_device_detail', args=[d1.pk])
+ t1 = self._create_template(name='t1', organization=None)
+ t2 = self._create_template(name='t2', organization=org1)
+ data = {'config': {'templates': [str(t1.id), str(t2.id)]}}
+ r = self.client.patch(path, data, content_type='application/json')
+ self.assertEqual(r.status_code, 200)
+ self.assertEqual(d1.config.templates.count(), 2)
+ self.assertEqual(r.data['config']['templates'], [t1.id, t2.id])
+
+ def test_device_patch_with_templates_of_different_org(self):
+ org1 = self._create_org(name='testorg')
+ d1 = self._create_device(name='org1-config', organization=org1)
+ self._create_config(device=d1)
+ self.assertEqual(d1.config.templates.count(), 0)
+ path = reverse('controller_config:api_device_detail', args=[d1.pk])
+ t1 = self._create_template(name='t1', organization=None)
+ t2 = self._create_template(name='t2', organization=org1)
+ t3 = self._create_template(
+ name='t3', organization=self._create_org(name='org2')
+ )
+ data = {'config': {'templates': [str(t1.id), str(t2.id), str(t3.id)]}}
+ with self.assertRaises(ValidationError) as error:
+ self.client.patch(path, data, content_type='application/json')
+ validation_msg = '''
+ The following templates are owned by
+ organizations which do not match the
+ organization of this configuration: t3
+ '''
+ self.assertTrue(' '.join(validation_msg.split()) in error.exception.message)
+
+ def test_device_patch_api(self):
+ d1 = self._create_device(name='test-device')
+ path = reverse('controller_config:api_device_detail', args=[d1.pk])
+ data = dict(name='change-test-device')
+ r = self.client.patch(path, data, content_type='application/json')
+ self.assertEqual(r.status_code, 200)
+ self.assertEqual(r.data['name'], 'change-test-device')
+
+ def test_device_download_api(self):
+ d1 = self._create_device()
+ self._create_config(device=d1)
+ path = reverse('controller_config:api_download_device_config', args=[d1.pk])
+ with self.assertNumQueries(6):
+ r = self.client.get(path)
+ self.assertEqual(r.status_code, 200)
+
+ def test_device_delete_api(self):
+ d1 = self._create_device()
+ self._create_config(device=d1)
+ path = reverse('controller_config:api_device_detail', args=[d1.pk])
+ r = self.client.delete(path)
+ self.assertEqual(r.status_code, 204)
+ self.assertEqual(Device.objects.count(), 0)
+
+ def test_template_create_no_org_api(self):
+ self.assertEqual(Template.objects.count(), 0)
+ path = reverse('controller_config:api_template_list')
+ data = self._get_template_data.copy()
+ r = self.client.post(path, data, content_type='application/json')
+ self.assertEqual(Template.objects.count(), 1)
+ self.assertEqual(r.status_code, 201)
+ self.assertEqual(r.data['organization'], None)
+
+ def test_template_create_vpn_with_type_as_generic(self):
+ path = reverse('controller_config:api_template_list')
+ test_user = self._create_operator(organizations=[self._get_org()])
+ self.client.force_login(test_user)
+ vpn1 = self._create_vpn(name='vpn1', organization=self._get_org())
+ data = self._get_template_data.copy()
+ data['organization'] = self._get_org().pk
+ data['type'] = 'generic'
+ data['vpn'] = vpn1.id
+ r = self.client.post(path, data, content_type='application/json')
+ validation_msg = "To select a VPN, set the template type to 'VPN-client'"
+ self.assertIn(validation_msg, r.data['vpn'])
+ self.assertEqual(r.status_code, 400)
+
+ def test_template_create_api(self):
+ self.assertEqual(Template.objects.count(), 0)
+ org = self._get_org()
+ path = reverse('controller_config:api_template_list')
+ data = self._get_template_data.copy()
+ data['organization'] = org.pk
+ data['required'] = True
+ r = self.client.post(path, data, content_type='application/json')
+ self.assertEqual(Template.objects.count(), 1)
+ self.assertEqual(r.status_code, 201)
+ self.assertEqual(r.data['organization'], org.pk)
+
+ def test_template_create_of_vpn_type(self):
+ org = self._get_org()
+ vpn1 = self._create_vpn(name='vpn1', organization=org)
+ path = reverse('controller_config:api_template_list')
+ data = self._get_template_data.copy()
+ data['type'] = 'vpn'
+ data['vpn'] = vpn1.id
+ data['organization'] = org.pk
+ r = self.client.post(path, data, content_type='application/json')
+ self.assertEqual(Template.objects.count(), 1)
+ self.assertEqual(r.status_code, 201)
+
+ def test_template_create_with_shared_vpn(self):
+ org1 = self._get_org()
+ test_user = self._create_operator(organizations=[org1])
+ self.client.force_login(test_user)
+ vpn1 = self._create_vpn(name='vpn1', organization=None)
+ path = reverse('controller_config:api_template_list')
+ data = self._get_template_data.copy()
+ data['type'] = 'vpn'
+ data['vpn'] = vpn1.id
+ data['organization'] = org1.pk
+ r = self.client.post(path, data, content_type='application/json')
+ self.assertEqual(r.status_code, 201)
+ self.assertEqual(Template.objects.count(), 1)
+ self.assertEqual(r.data['vpn'], vpn1.id)
+
+ def test_template_creation_with_no_org_by_operator(self):
+ path = reverse('controller_config:api_template_list')
+ data = self._get_template_data.copy()
+ test_user = self._create_operator(organizations=[self._get_org()])
+ self.client.force_login(test_user)
+ r = self.client.post(path, data, content_type='application/json')
+ self.assertEqual(r.status_code, 400)
+ self.assertIn('This field may not be null.', str(r.content))
+
+ def test_template_create_with_empty_config(self):
+ path = reverse('controller_config:api_template_list')
+ data = self._get_template_data.copy()
+ data['config'] = {}
+ data['organization'] = self._get_org().pk
+ r = self.client.post(path, data, content_type='application/json')
+ self.assertEqual(r.status_code, 400)
+ self.assertIn('The configuration field cannot be empty.', str(r.content))
+
+ def test_template_list_api(self):
+ org1 = self._get_org()
+ self._create_template(name='t1', organization=org1)
+ path = reverse('controller_config:api_template_list')
+ with self.assertNumQueries(5):
+ r = self.client.get(path)
+ self.assertEqual(r.status_code, 200)
+ self.assertEqual(Template.objects.count(), 1)
+
+ def test_template_list_for_shared_objects(self):
+ org1 = self._get_org()
+ self._create_vpn(name='shared-vpn', organization=None)
+ test_user = self._create_operator(organizations=[org1])
+ self.client.force_login(test_user)
+ path = reverse('controller_config:api_template_list')
+ r = self.client.get(path, {'format': 'api'})
+ self.assertEqual(r.status_code, 200)
+ self.assertContains(r, 'shared-vpn')
+
+ # template-detail having no Org
+ def test_template_detail_api(self):
+ t1 = self._create_template(name='t1')
+ path = reverse('controller_config:api_template_detail', args=[t1.pk])
+ with self.assertNumQueries(4):
+ r = self.client.get(path)
+ self.assertEqual(r.status_code, 200)
+ self.assertEqual(r.data['organization'], None)
+
+ def test_template_put_api(self):
+ t1 = self._create_template(name='t1', organization=None)
+ path = reverse('controller_config:api_template_detail', args=[t1.pk])
+ org = self._get_org()
+ data = {
+ 'name': 'New t1',
+ 'required': True,
+ 'organization': org.pk,
+ 'backend': 'netjsonconfig.OpenWrt',
+ 'config': {'interfaces': [{'name': 'eth0', 'type': 'ethernet'}]},
+ }
+ r = self.client.put(path, data, content_type='application/json')
+ self.assertEqual(r.status_code, 200)
+ self.assertEqual(r.data['name'], 'New t1')
+ self.assertEqual(r.data['organization'], org.pk)
+ self.assertEqual(r.data['required'], True)
+
+ def test_template_patch_api(self):
+ t1 = self._create_template(name='t1')
+ path = reverse('controller_config:api_template_detail', args=[t1.pk])
+ data = dict(name='New t1')
+ r = self.client.patch(path, data, content_type='application/json')
+ self.assertEqual(r.status_code, 200)
+ self.assertEqual(r.data['name'], 'New t1')
+
+ def test_template_download_api(self):
+ t1 = self._create_template(name='t1')
+ path = reverse('controller_config:api_download_template_config', args=[t1.pk])
+ with self.assertNumQueries(3):
+ r = self.client.get(path)
+ self.assertEqual(r.status_code, 200)
+
+ def test_template_delete_api(self):
+ t1 = self._create_template(name='t1')
+ path = reverse('controller_config:api_template_detail', args=[t1.pk])
+ r = self.client.delete(path)
+ self.assertEqual(r.status_code, 204)
+ self.assertEqual(Template.objects.count(), 0)
+
+ def test_vpn_create_api(self):
+ self.assertEqual(Vpn.objects.count(), 0)
+ path = reverse('controller_config:api_vpn_list')
+ ca1 = self._create_ca()
+ data = self._get_vpn_data.copy()
+ data['ca'] = ca1.pk
+ r = self.client.post(path, data, content_type='application/json')
+ self.assertEqual(r.status_code, 201)
+ self.assertEqual(Vpn.objects.count(), 1)
+
+ def test_vpn_create_with_shared_objects(self):
+ org1 = self._get_org()
+ shared_ca = self._create_ca(name='shared_ca', organization=None)
+ test_user = self._create_operator(organizations=[org1])
+ self.client.force_login(test_user)
+ data = self._get_vpn_data.copy()
+ data['organization'] = org1.pk
+ data['ca'] = shared_ca.pk
+ path = reverse('controller_config:api_vpn_list')
+ r = self.client.post(path, data, content_type='application/json')
+ self.assertEqual(Vpn.objects.count(), 1)
+ self.assertEqual(r.status_code, 201)
+ self.assertEqual(r.data['ca'], shared_ca.pk)
+
+ def test_vpn_list_api(self):
+ org = self._get_org()
+ self._create_vpn(organization=org)
+ path = reverse('controller_config:api_vpn_list')
+ with self.assertNumQueries(4):
+ r = self.client.get(path)
+ self.assertEqual(r.status_code, 200)
+
+ def test_vpn_list_for_shared_objects(self):
+ self._create_ca(name='shared_ca', organization=None)
+ self._create_cert(name='shared_cert', organization=None)
+ org1 = self._get_org()
+ test_user = self._create_operator(organizations=[org1])
+ self.client.force_login(test_user)
+ path = reverse('controller_config:api_vpn_list')
+ r = self.client.get(path, {'format': 'api'})
+ self.assertEqual(r.status_code, 200)
+ self.assertContains(r, 'shared_ca')
+ self.assertContains(r, 'shared_cert')
+
+ # VPN detail having no Org
+ def test_vpn_detail_no_org_api(self):
+ vpn1 = self._create_vpn(name='test-vpn')
+ path = reverse('controller_config:api_vpn_detail', args=[vpn1.pk])
+ with self.assertNumQueries(3):
+ r = self.client.get(path)
+ self.assertEqual(r.status_code, 200)
+ self.assertEqual(r.data['organization'], None)
+
+ # VPN detail having Org
+ def test_vpn_detail_with_org_api(self):
+ org = self._get_org()
+ vpn1 = self._create_vpn(name='test-vpn', organization=org)
+ path = reverse('controller_config:api_vpn_detail', args=[vpn1.pk])
+ with self.assertNumQueries(3):
+ r = self.client.get(path)
+ self.assertEqual(r.status_code, 200)
+ self.assertEqual(r.data['organization'], org.pk)
+
+ def test_vpn_put_api(self):
+ vpn1 = self._create_vpn(name='test-vpn')
+ path = reverse('controller_config:api_vpn_detail', args=[vpn1.pk])
+ org = self._get_org()
+ ca1 = self._create_ca()
+ data = {
+ 'name': 'change-test-vpn',
+ 'host': 'vpn1.changetest.com',
+ 'organization': org.pk,
+ 'ca': ca1.pk,
+ 'backend': vpn1.backend,
+ 'config': vpn1.config,
+ }
+ r = self.client.put(path, data, content_type='application/json')
+ self.assertEqual(r.status_code, 200)
+ self.assertEqual(r.data['name'], 'change-test-vpn')
+ self.assertEqual(r.data['ca'], ca1.pk)
+ self.assertEqual(r.data['organization'], org.pk)
+
+ def test_vpn_patch_api(self):
+ vpn1 = self._create_vpn(name='test-vpn')
+ path = reverse('controller_config:api_vpn_detail', args=[vpn1.pk])
+ data = dict(name='test-vpn-change')
+ r = self.client.patch(path, data, content_type='application/json')
+ self.assertEqual(r.status_code, 200)
+ self.assertEqual(r.data['name'], 'test-vpn-change')
+
+ def test_vpn_download_api(self):
+ vpn1 = self._create_vpn(name='test-vpn')
+ path = reverse('controller_config:api_download_vpn_config', args=[vpn1.pk])
+ with self.assertNumQueries(5):
+ r = self.client.get(path)
+ self.assertEqual(r.status_code, 200)
+
+ def test_vpn_delete_api(self):
+ vpn1 = self._create_vpn(name='test-vpn')
+ path = reverse('controller_config:api_vpn_detail', args=[vpn1.pk])
+ r = self.client.delete(path)
+ self.assertEqual(r.status_code, 204)
+ self.assertEqual(Vpn.objects.count(), 0)
diff --git a/openwisp_controller/urls.py b/openwisp_controller/urls.py
index fde5a3db3..4dba6738e 100644
--- a/openwisp_controller/urls.py
+++ b/openwisp_controller/urls.py
@@ -44,6 +44,12 @@
'app': 'openwisp_controller.config',
'include': {'module': '{app}.urls', 'namespace': 'config'},
},
+ # openwisp_controller.config.api
+ {
+ 'regexp': r'^api/v1/',
+ 'app': 'openwisp_controller.config',
+ 'include': {'module': '{app}.api.urls', 'namespace': 'controller_config'},
+ },
]
urlpatterns = []
diff --git a/tests/openwisp2/sample_config/api/views.py b/tests/openwisp2/sample_config/api/views.py
new file mode 100644
index 000000000..a3dd6e74b
--- /dev/null
+++ b/tests/openwisp2/sample_config/api/views.py
@@ -0,0 +1,70 @@
+from openwisp_controller.config.api.views import (
+ DeviceDetailView as BaseDeviceDetailView,
+)
+from openwisp_controller.config.api.views import (
+ DeviceListCreateView as BaseDeviceListCreateView,
+)
+from openwisp_controller.config.api.views import (
+ DownloadDeviceView as BaseDownloadDeviceView,
+)
+from openwisp_controller.config.api.views import (
+ DownloadTemplateconfiguration as BaseDownloadTemplateconfiguration,
+)
+from openwisp_controller.config.api.views import DownloadVpnView as BaseDownloadVpnView
+from openwisp_controller.config.api.views import (
+ TemplateDetailView as BaseTemplateDetailView,
+)
+from openwisp_controller.config.api.views import (
+ TemplateListCreateView as BaseTemplateListCreateView,
+)
+from openwisp_controller.config.api.views import VpnDetailView as BaseVpnDetailView
+from openwisp_controller.config.api.views import (
+ VpnListCreateView as BaseVpnListCreateView,
+)
+
+
+class TemplateListCreateView(BaseTemplateListCreateView):
+ pass
+
+
+class TemplateDetailView(BaseTemplateDetailView):
+ pass
+
+
+class DownloadTemplateconfiguration(BaseDownloadTemplateconfiguration):
+ pass
+
+
+class VpnListCreateView(BaseVpnListCreateView):
+ pass
+
+
+class VpnDetailView(BaseVpnDetailView):
+ pass
+
+
+class DownloadVpnView(BaseDownloadVpnView):
+ pass
+
+
+class DeviceListCreateView(BaseDeviceListCreateView):
+ pass
+
+
+class DeviceDetailView(BaseDeviceDetailView):
+ pass
+
+
+class DownloadDeviceView(BaseDownloadDeviceView):
+ pass
+
+
+template_list = TemplateListCreateView.as_view()
+template_detail = TemplateDetailView.as_view()
+download_template_config = DownloadTemplateconfiguration.as_view()
+vpn_list = VpnListCreateView.as_view()
+vpn_detail = VpnDetailView.as_view()
+download_vpn_config = DownloadVpnView.as_view()
+device_list = DeviceListCreateView.as_view()
+device_detail = DeviceDetailView.as_view()
+download_device_config = DownloadDeviceView().as_view()
diff --git a/tests/openwisp2/sample_config/tests.py b/tests/openwisp2/sample_config/tests.py
index 005a5cb4d..5b2f4cf94 100644
--- a/tests/openwisp2/sample_config/tests.py
+++ b/tests/openwisp2/sample_config/tests.py
@@ -1,4 +1,5 @@
from openwisp_controller.config.tests.test_admin import TestAdmin as BaseTestAdmin
+from openwisp_controller.config.tests.test_api import TestConfigApi as BaseTestConfigApi
from openwisp_controller.config.tests.test_apps import TestApps as BaseTestApps
from openwisp_controller.config.tests.test_config import TestConfig as BaseTestConfig
from openwisp_controller.config.tests.test_controller import (
@@ -70,6 +71,10 @@ class TestApps(BaseTestApps):
pass
+class TestConfigApi(BaseTestConfigApi):
+ pass
+
+
del BaseTestAdmin
del BaseTestConfig
del BaseTestController
@@ -82,3 +87,4 @@ class TestApps(BaseTestApps):
del BaseTestVpn
del BaseTestVpnTransaction
del BaseTestApps
+del BaseTestConfigApi
diff --git a/tests/openwisp2/settings.py b/tests/openwisp2/settings.py
index eb353711c..978e65826 100644
--- a/tests/openwisp2/settings.py
+++ b/tests/openwisp2/settings.py
@@ -51,7 +51,10 @@
'flat_json_widget',
# rest framework
'rest_framework',
+ 'rest_framework.authtoken',
'rest_framework_gis',
+ 'django_filters',
+ 'drf_yasg',
# channels
'channels',
# 'debug_toolbar',
diff --git a/tests/openwisp2/urls.py b/tests/openwisp2/urls.py
index 3abf5ab28..c5dee7642 100644
--- a/tests/openwisp2/urls.py
+++ b/tests/openwisp2/urls.py
@@ -1,17 +1,19 @@
import os
from django.conf import settings
-from django.conf.urls import include, url
+from django.conf.urls import url
from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
-from django.urls import path, reverse_lazy
+from django.urls import include, path, reverse_lazy
from django.views.generic import RedirectView
+from openwisp_controller.config.api.urls import get_api_urls
from openwisp_controller.config.utils import get_controller_urls
from openwisp_controller.geo.utils import get_geo_urls
from .sample_config import views as config_views
+from .sample_config.api import views as api_views
from .sample_geo import views as geo_views
redirect_view = RedirectView.as_view(url=reverse_lazy('admin:index'))
@@ -32,6 +34,13 @@
include(('openwisp_controller.config.urls', 'config'), namespace='config'),
),
url(r'^geo/', include((get_geo_urls(geo_views), 'geo'), namespace='geo')),
+ url(
+ r'^api/v1/',
+ include(
+ (get_api_urls(api_views), 'controller_config'),
+ namespace='controller_config',
+ ),
+ ),
]
urlpatterns += [
@@ -39,6 +48,7 @@
url(r'^admin/', admin.site.urls),
url(r'', include('openwisp_controller.urls')),
path('accounts/', include('openwisp_users.accounts.urls')),
+ path('api/v1/', include('openwisp_utils.api.urls')),
]
urlpatterns += staticfiles_urlpatterns()