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()