From 06a46c331638524a1901e343f7388b504c0b9a41 Mon Sep 17 00:00:00 2001 From: ManishShah120 Date: Fri, 9 Jul 2021 12:24:54 +0530 Subject: [PATCH] [change] Removed `DeviceLocation` endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Chnaged the endpoints pattern - Removed tests for `DeviceLocation` endpoints - Updated docs with new pattern of endpoints [api] Updated the `devicelocation` view in `GEO` app endpoint [api] Added the delete option for the `device_location` endpoint [docs] Added `delete` in the device location API documentation [fix] Context variable follows template order #484 If two or more applied templates have "default_values" with same keys, then the context variables of the template with comes later in order will be used. Fixes #484 Co-authored-by: Federico Capoano [feature] Added support for ED25519 SSH keys Additionally shows a validation error if the private key being inserted during credential creation is invalid or not supported (supporting only RSA and ED25519 since they're the widely accepted secure algorithms). [feature] Allow searching for address in Device Admin [change] Execute credentials auto_add in the background #479 Closes #479 [feature] Added device groups #203 A group can be specified for devices, i.e. DeviceGroup. DeviceGroup can contain metadata about group of devices in JSON format. JSONSchema can be set systemwide for validating and building the UI users will use to fill the metadata. Added REST API endpoint for listing, creating and retrieving DeviceGroups. Implements and closes #203 [feature] Added connection app REST API #464 Closes #464 [deps] Increased min django-flat-json version to 0.1.3 #502 Fixes #502 [chores] Admin improvements for groups - added group filter in device list - added description and meta_data to search_fields [feature] Added REST API for PKI app (certs and CAs) #462 Implements and closes #462 [feature] Add API endpoint to return device group info based on the certificate common name #491 - [change!] Common name and Organization unique together for Cert and Ca models - [deps] Added shortuuid~=1.0.1 - [feature] Added API endpoint to return device group using certificate common name #491 Closes #491 [fix] Fixed JSONSchema editor select2 fields getting disabled #505 The issue arose because the JSON Schema library uses selec2('enable') for enabling or disabling select2 fields. According to the "Migrating from 3.5" section in select2 documentation, select2('enable') has been deprecated. The solution is to override the methods using it to simply modify select2.disabled property. Closes #505 [fix] Fixed advanced editor quirks #506 Bugs fixed: - Executing a command, would raise a 'Invalid JSON' alert in the advance mode of the configuration even if the JSON is valid - The device page deals with two schema and the container for advance mode editor used 'id="advanced_editor"'. Fetching the advance mode editor using the 'id' always returned the first occurence (advance mode editor of configuration). This used to create multiple advance mode editor DOM elements inside the container all of which had their own event listeners. Hence, full screen toggling was not working properly. Closes #506 [fix] JSONSchema Editor maxlength modification handle non-existent schema objects #353 Related to #353 [change] Switch to new nav menu #472 Closes #472 [api] Re-introduced `DeviceLocation` endpoints [api] Upgraded existing Device Location endpoint [tests] Added tests for device location endpoint [change] Minor code improvements [tests] Added tests for changing location detail and coordinates [api] Added support of creating a floorplan along with location [api] Added update method to `Location` serializer [api] Added support of creating/updating floorplan with location [change] Added option to change device location detail with token [tests] Added tests for create location endpoint of indoor type [change] Minor bug fix and added tests for the endpoints [tests] Minor typo fix [change] Improved docs & optimized number of queries [docs] Improvement in the docs related to devicelocation endpoint [api] Fixed device location endpoint added TokenAuthentication [docs] Updated info on how to use the device location endpoints [change] Added tests and improved devicelocation permission logic [change] Fixed the validation error raised due to image format [change!] Removed the creation of devicelocation with GET request [fix] Included PKI API URLs to `controller.urls` file #511 Closes #511 [chores] Ensure Device.group.verbose_name is lowercase for consistency [fix] Fixed 0010 pki migration when cert serial_number is None [fix] CommandFailedException: ensure error message is always present If a command with suppressed output failed, CommandFailedException would be raised with an emptry string as argument, which makes debugging issues really hard. In this cases we shall instantiate the exception with the same message passed to the log. [fix/tests] TestSsh: fixed assert_has_calls not being called I found out these assertions were not being called while working on the previous commit. [fix] Registered menu group in connection app #512 Closes #512 [fix] Fixed new theme issues in config editor and command inputs Co-authored-by: Federico Capoano [test] Fixed failing tests due to openwisp-utils menu changes Co-authored-by: Pedro Peña [docs] Updated docs about limitations of device location endpoint [tests] Fixed tests causing CI to fail --- README.rst | 154 +++--- openwisp_controller/config/api/views.py | 11 +- openwisp_controller/connection/api/views.py | 2 + openwisp_controller/geo/api/serializers.py | 215 +++++++- openwisp_controller/geo/api/views.py | 49 +- openwisp_controller/geo/tests/test_api.py | 526 +++++++++++++++++--- openwisp_controller/geo/utils.py | 24 +- tests/openwisp2/sample_geo/views.py | 16 - 8 files changed, 772 insertions(+), 225 deletions(-) diff --git a/README.rst b/README.rst index 11ece561c..a350e566a 100755 --- a/README.rst +++ b/README.rst @@ -1005,167 +1005,201 @@ of certificate's organization as show in the example below: GET /api/v1/controller/cert/{common_name}/group/?org={org1_slug},{org2_slug} -Get device coordinates -^^^^^^^^^^^^^^^^^^^^^^ +Get device location +^^^^^^^^^^^^^^^^^^^ .. code-block:: text GET /api/v1/controller/device/{id}/location/ -Update device coordinates -^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. code-block:: text - - PUT /api/v1/controller/device/{id}/location/ - -List of devices in a location -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +The device location endpoints can be accessed in two ways: +"""""""""""""""""""""""""""""""""""""""""""""""""""""""""" -.. code:: text +* As all the other endpoints it can be accessed + with token or session authentication, when this + happens permissions and multi-tenancy is respected. - GET /api/v1/controller/location/{id}/device/ +* When device key is passed as ``query_param``, we assume + the device itself is updating its position, so no check for + multi-tenancy or permissions is performed. -List locations with devices deployed (in GeoJSON format) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. code:: text - - GET /api/v1/controller/location/geojson/ +Update device location +^^^^^^^^^^^^^^^^^^^^^^ -You can filter using ``organization_slug`` to list location of -devices from that organization +.. code-block:: text -.. code:: text + PUT /api/v1/controller/device/{id}/location/ - GET /api/v1/controller/location/geojson/?organization_slug= +**Note**:- To access this endpoints, `see here <#the-device-location-endpoints-can-be-accesseed-in-two-ways>`_ +Patch details of device location +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -List devices with location -^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. code-block:: text -.. code:: text + PATCH /api/v1/controller/device/{id}/location/ - GET /api/v1/geo/devicelocation/ +**Note**:- To access this endpoints, `see here <#the-device-location-endpoints-can-be-accesseed-in-two-ways>`_ -Add location to device +Delete device location ^^^^^^^^^^^^^^^^^^^^^^ -.. code:: text - - POST /api/v1/geo/devicelocation/ +.. code-block:: text -Get devices with location detail -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + DELETE /api/v1/controller/device/{id}/location/ -.. code:: text +**Note**:- To access this endpoints, `see here <#the-device-location-endpoints-can-be-accesseed-in-two-ways>`_. +Presently, with these endpoints it is not possible to +assign an existing location or floorplan to a device, +however, it allows us to update and delete. - GET /api/v1/geo/devicelocation/{id}/ - -Change device location detail +List of devices in a location ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. code:: text - PUT /api/v1/geo/devicelocation/{id}/ - -Patch device location detail -^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. code:: text - - PATCH /api/v1/geo/devicelocation/{id}/ + GET /api/v1/controller/location/{id}/device/ -Delete device location -^^^^^^^^^^^^^^^^^^^^^^ +List locations with devices deployed (in GeoJSON format) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. code:: text - DELETE /api/v1/geo/devicelocation/{id}/ + GET /api/v1/controller/location/geojson/ List floorplan ^^^^^^^^^^^^^^ .. code:: text - GET /api/v1/geo/floorplan/ + GET /api/v1/controller/floorplan/ Create floorplan ^^^^^^^^^^^^^^^^ .. code:: text - POST /api/v1/geo/floorplan/ + POST /api/v1/controller/floorplan/ Get floorplan detail ^^^^^^^^^^^^^^^^^^^^ .. code:: text - GET /api/v1/geo/floorplan/{pk}/ + GET /api/v1/controller/floorplan/{pk}/ Change floorplan detail ^^^^^^^^^^^^^^^^^^^^^^^ .. code:: text - PUT /api/v1/geo/floorplan/{pk}/ + PUT /api/v1/controller/floorplan/{pk}/ Patch details of floorplan ^^^^^^^^^^^^^^^^^^^^^^^^^^ .. code:: text - PATCH /api/v1/geo/floorplan/{pk}/ + PATCH /api/v1/controller/floorplan/{pk}/ Delete floorplan ^^^^^^^^^^^^^^^^ .. code:: text - DELETE /api/v1/geo/floorplan/{pk}/ + DELETE /api/v1/controller/floorplan/{pk}/ List locations ^^^^^^^^^^^^^^ .. code:: text - GET /api/v1/geo/location/ + GET /api/v1/controller/location/ Create locations ^^^^^^^^^^^^^^^^ .. code:: text - POST /api/v1/geo/location/ + POST /api/v1/controller/location/ Get location detail ^^^^^^^^^^^^^^^^^^^ .. code:: text - GET /api/v1/geo/location/{pk}/ + GET /api/v1/controller/location/{pk}/ Change location detail ^^^^^^^^^^^^^^^^^^^^^^ .. code:: text - PUT /api/v1/geo/location/{pk}/ + PUT /api/v1/controller/location/{pk}/ Patch details of location ^^^^^^^^^^^^^^^^^^^^^^^^^ .. code:: text - PATCH /api/v1/geo/location/{pk}/ + PATCH /api/v1/controller/location/{pk}/ + +**Note**: Only the first floorplan data present can be +edited or changed. Setting the `type` of location to +outdoor will remove all the floorplans associated with it. + +**Example usage**: To change the floorplan data of a location + +.. code-block:: shell + + echo '{"floorplan":{"floor": 11}}' | \ + http PATCH http://127.0.0.1:8000/api/v1/controller/location/76b7d9cc-4ffd-4a43-b1b0-8f8befd1a7c0/ Delete location ^^^^^^^^^^^^^^^ .. code:: text - DELETE /api/v1/geo/location/{pk}/ + DELETE /api/v1/controller/location/{pk}/ + +List device groups +^^^^^^^^^^^^^^^^^^ + +.. code:: text + + GET api/v1/controller/group/ + +Create device group +^^^^^^^^^^^^^^^^^^^ + +.. code:: text + + POST api/v1/controller/group/ + +Get device group detail +^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: text + + GET /api/v1/controller/group/{id}/ + +Get device group from certificate common name +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: text + + GET /api/v1/controller/cert/{common_name}/group/ + +This endpoint can be used to retrieve group information and metadata by the +common name of a certificate used in a VPN client tunnel, this endpoint is +used in layer 2 tunneling solutions for firewall/captive portals. + +It is also possible to filter device group by providing organization slug +of certificate's organization as show in the example below: + +.. code-block:: text + + GET /api/v1/controller/cert/{common_name}/group/?org={org1_slug},{org2_slug} List templates ^^^^^^^^^^^^^^ diff --git a/openwisp_controller/config/api/views.py b/openwisp_controller/config/api/views.py index 0eedef453..20f970f50 100644 --- a/openwisp_controller/config/api/views.py +++ b/openwisp_controller/config/api/views.py @@ -121,10 +121,7 @@ class DeviceGroupDetailView(ProtectedAPIMixin, RetrieveUpdateDestroyAPIView): def get_cached_devicegroup_args_rewrite(cls, org_slugs, common_name): - url = reverse( - 'config_api:devicegroup_x509_commonname', - args=[common_name], - ) + url = reverse('config_api:devicegroup_x509_commonname', args=[common_name]) url = f'{url}?org={org_slugs}' return url @@ -184,11 +181,7 @@ def _invalidate_from_queryset(cls, queryset): @classmethod def device_change_invalidates_cache(cls, device_id): qs = ( - VpnClient.objects.select_related( - 'config', - 'organization', - 'cert', - ) + VpnClient.objects.select_related('config', 'organization', 'cert',) .filter(config__device_id=device_id) .annotate( organization__slug=F('cert__organization__slug'), diff --git a/openwisp_controller/connection/api/views.py b/openwisp_controller/connection/api/views.py index 28d0d04ab..1ab62678f 100644 --- a/openwisp_controller/connection/api/views.py +++ b/openwisp_controller/connection/api/views.py @@ -12,6 +12,8 @@ from swapper import load_model from openwisp_users.api.authentication import BearerAuthentication +from openwisp_users.api.mixins import FilterByOrganizationManaged +from openwisp_users.api.permissions import DjangoModelPermissions from ...mixins import ProtectedAPIMixin from .serializer import ( diff --git a/openwisp_controller/geo/api/serializers.py b/openwisp_controller/geo/api/serializers.py index d640abac9..c14effeb6 100644 --- a/openwisp_controller/geo/api/serializers.py +++ b/openwisp_controller/geo/api/serializers.py @@ -1,4 +1,11 @@ +import io + +from django.contrib.humanize.templatetags.humanize import ordinal +from django.core.files.uploadedfile import InMemoryUploadedFile +from django.db import transaction from django.urls import reverse +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers from rest_framework.serializers import IntegerField, SerializerMethodField from rest_framework_gis import serializers as gis_serializers from swapper import load_model @@ -12,14 +19,6 @@ FloorPlan = load_model('geo', 'FloorPlan') -class LocationSerializer(gis_serializers.GeoFeatureModelSerializer): - class Meta: - model = Location - geo_field = 'geometry' - fields = ('name', 'geometry') - read_only_fields = ('name',) - - class LocationDeviceSerializer(ValidatedModelSerializer): admin_edit_url = SerializerMethodField('get_admin_edit_url') @@ -47,10 +46,13 @@ class BaseSerializer(FilterSerializerByOrgManaged, ValidatedModelSerializer): class FloorPlanSerializer(BaseSerializer): + name = serializers.SerializerMethodField() + class Meta: model = FloorPlan fields = ( 'id', + 'name', 'floor', 'image', 'location', @@ -59,6 +61,10 @@ class Meta: ) read_only_fields = ('created', 'modified') + def get_name(self, obj): + name = '{0} {1} Floor'.format(obj.location.name, ordinal(obj.floor)) + return name + def validate(self, data): if data.get('location'): data['organization'] = data.get('location').organization @@ -67,7 +73,19 @@ def validate(self, data): return data -class LocationModelSerializer(BaseSerializer): +class FloorPlanLocationSerializer(serializers.ModelSerializer): + class Meta: + model = FloorPlan + fields = ( + 'floor', + 'image', + ) + extra_kwargs = {'floor': {'required': False}, 'image': {'required': False}} + + +class LocationSerializer(FilterSerializerByOrgManaged, serializers.ModelSerializer): + floorplan = FloorPlanLocationSerializer(required=False, allow_null=True) + class Meta: model = Location fields = ( @@ -80,20 +98,185 @@ class Meta: 'geometry', 'created', 'modified', + 'floorplan', ) read_only_fields = ('created', 'modified') + def validate(self, data): + if data.get('type') == 'outdoor' and data.get('floorplan'): + raise serializers.ValidationError( + { + 'type': _( + "Floorplan can only be added with location of " + "the type indoor" + ) + } + ) + return data + + def to_representation(self, instance): + request = self.context['request'] + data = super().to_representation(instance) + floorplans = instance.floorplan_set.all().order_by('-modified') + floorplan_list = [] + for floorplan in floorplans: + dict_ = { + 'floor': floorplan.floor, + 'image': request.build_absolute_uri(floorplan.image.url), + } + floorplan_list.append(dict_) + data['floorplan'] = floorplan_list + return data + + def create(self, validated_data): + floorplan_data = None + + if validated_data.get('floorplan'): + floorplan_data = validated_data.pop('floorplan') + + instance = self.instance or self.Meta.model(**validated_data) + with transaction.atomic(): + instance.full_clean() + instance.save() + + if floorplan_data: + floorplan_data['location'] = instance + floorplan_data['organization'] = instance.organization + with transaction.atomic(): + fl = FloorPlan.objects.create(**floorplan_data) + fl.full_clean() + fl.save() + + return instance + + def update(self, instance, validated_data): + floorplan_data = None + if validated_data.get('floorplan'): + floorplan_data = validated_data.pop('floorplan') + + if floorplan_data: + floorplan_obj = instance.floorplan_set.order_by('-created').first() + if floorplan_obj: + # Update the first floorplan object + floorplan_obj.floor = floorplan_data.get('floor', floorplan_obj.floor) + floorplan_obj.image = floorplan_data.get('image', floorplan_obj.image) + with transaction.atomic(): + floorplan_obj.full_clean() + floorplan_obj.save() + else: + if validated_data.get('type') == 'indoor': + instance.type = 'indoor' + instance.save() + floorplan_data['location'] = instance + floorplan_data['organization'] = instance.organization + fl = FloorPlan.objects.create(**floorplan_data) + with transaction.atomic(): + fl.full_clean() + fl.save() + + if instance.type == 'indoor' and validated_data.get('type') == 'outdoor': + floorplans = instance.floorplan_set.all() + for floorplan in floorplans: + floorplan.delete() + + return super().update(instance, validated_data) + + +class NestedtLocationSerializer(gis_serializers.GeoFeatureModelSerializer): + class Meta: + model = Location + geo_field = 'geometry' + fields = ( + 'type', + 'is_mobile', + 'name', + 'address', + 'geometry', + ) + + +class NestedFloorplanSerializer(serializers.ModelSerializer): + class Meta: + model = FloorPlan + fields = ( + 'floor', + 'image', + ) + + +class DeviceLocationSerializer(serializers.ModelSerializer): + location = NestedtLocationSerializer() + floorplan = NestedFloorplanSerializer(required=False, allow_null=True) -class DeviceLocationSerializer(BaseSerializer): class Meta: model = DeviceLocation fields = ( - 'id', - 'indoor', - 'content_object', 'location', 'floorplan', - 'created', - 'modified', + 'indoor', ) - read_only_fields = ('created', 'modified') + + def to_internal_value(self, value): + if value.get('location.type') == 'outdoor' and not self.instance.floorplan: + value._mutable = True + value.pop('floorplan.floor') + value.pop('floorplan.image') + value.pop('indoor') + value._mutable = False + + if value.get('floorplan'): + if value.get('floorplan').get('image'): + if type(value.get('floorplan').get('image')) is str: + _image = self.instance.floorplan.image + io_image = io.BytesIO(_image.read()) + image = InMemoryUploadedFile( + file=io_image, + name=_image.name, + field_name='floorplan.image', + content_type='image/jpeg', + size=_image.size, + charset=None, + ) + value['floorplan']['image'] = image + value = super().to_internal_value(value) + return value + + def update(self, instance, validated_data): + if 'location' in validated_data: + location_data = validated_data.pop('location') + location = instance.location + if location.type == 'indoor' and location_data.get('type') == 'outdoor': + instance.floorplan = None + validated_data['indoor'] = "" + location.type = location_data.get('type', location.type) + location.is_mobile = location_data.get('is_mobile', location.is_mobile) + location.name = location_data.get('name', location.name) + location.address = location_data.get('address', location.address) + location.geometry = location_data.get('geometry', location.geometry) + location.save() + + if 'floorplan' in validated_data: + floorplan_data = validated_data.pop('floorplan') + if instance.location.type == 'indoor': + if instance.floorplan: + floorplan = instance.floorplan + floorplan.floor = floorplan_data.get('floor', floorplan.floor) + floorplan.image = floorplan_data.get('image', floorplan.image) + floorplan.full_clean() + floorplan.save() + if ( + instance.location.type == 'outdoor' + and location_data['type'] == 'indoor' + ): + fl = FloorPlan.objects.create( + floor=floorplan_data['floor'], + organization=instance.content_object.organization, + image=floorplan_data['image'], + location=instance.location, + ) + instance.location.type = 'indoor' + instance.location.full_clean() + instance.location.save() + instance.floorplan = fl + + return super().update(instance, validated_data) diff --git a/openwisp_controller/geo/api/views.py b/openwisp_controller/geo/api/views.py index 03feea04b..a69679129 100644 --- a/openwisp_controller/geo/api/views.py +++ b/openwisp_controller/geo/api/views.py @@ -15,7 +15,6 @@ FloorPlanSerializer, GeoJsonLocationSerializer, LocationDeviceSerializer, - LocationModelSerializer, LocationSerializer, ) @@ -54,19 +53,22 @@ def get_queryset(self): def get_location(self, device): try: - return device.devicelocation.location + return device.devicelocation except ObjectDoesNotExist: return None def get_object(self, *args, **kwargs): device = super().get_object() - location = self.get_location(device) - if location: - return location - # if no location present, automatically create it - return self.create_location(device) - - def create_location(self, device): + devicelocation = self.get_devicelocation(device) + if devicelocation: + return devicelocation + else: + if self.request.method in ('GET', 'PATCH', 'DELETE'): + raise Http404 + if self.request.method == 'PUT': + return self.create_devicelocation(device) + + def create_devicelocation(self, device): location = Location( name=device.name, type='outdoor', @@ -75,10 +77,10 @@ def create_location(self, device): ) location.full_clean() location.save() - dl = DeviceLocation(content_object=device, location=location) + dl = DeviceLocation(content_object=device, location=location, indoor="") dl.full_clean() dl.save() - return location + return dl class GeoJsonLocationListPagination(GeoJsonPagination): @@ -122,7 +124,7 @@ def get_queryset(self): class FloorPlanListCreateView(ProtectedAPIMixin, generics.ListCreateAPIView): serializer_class = FloorPlanSerializer - queryset = FloorPlan.objects.order_by('-created') + queryset = FloorPlan.objects.select_related().order_by('-created') pagination_class = ListViewPagination @@ -130,11 +132,11 @@ class FloorPlanDetailView( ProtectedAPIMixin, generics.RetrieveUpdateDestroyAPIView, ): serializer_class = FloorPlanSerializer - queryset = FloorPlan.objects.all() + queryset = FloorPlan.objects.select_related() class LocationListCreateView(ProtectedAPIMixin, generics.ListCreateAPIView): - serializer_class = LocationModelSerializer + serializer_class = LocationSerializer queryset = Location.objects.order_by('-created') pagination_class = ListViewPagination @@ -142,25 +144,10 @@ class LocationListCreateView(ProtectedAPIMixin, generics.ListCreateAPIView): class LocationDetailView( ProtectedAPIMixin, generics.RetrieveUpdateDestroyAPIView, ): - serializer_class = LocationModelSerializer + serializer_class = LocationSerializer queryset = Location.objects.all() -class DeviceLocationListCreateView(ProtectedAPIMixin, generics.ListCreateAPIView): - serializer_class = DeviceLocationSerializer - queryset = DeviceLocation.objects.order_by('-created') - organization_field = 'location__organization' - pagination_class = ListViewPagination - - -class DeviceLocationDetailView( - ProtectedAPIMixin, generics.RetrieveUpdateDestroyAPIView, -): - serializer_class = DeviceLocationSerializer - queryset = DeviceLocation.objects.all() - organization_field = 'location__organization' - - device_location = DeviceLocationView.as_view() geojson = GeoJsonLocationList.as_view() location_device_list = LocationDeviceList.as_view() @@ -168,5 +155,3 @@ class DeviceLocationDetailView( detail_floorplan = FloorPlanDetailView.as_view() list_location = LocationListCreateView.as_view() detail_location = LocationDetailView.as_view() -device_location_list = DeviceLocationListCreateView.as_view() -device_location_detail = DeviceLocationDetailView.as_view() diff --git a/openwisp_controller/geo/tests/test_api.py b/openwisp_controller/geo/tests/test_api.py index e03412839..24513b125 100644 --- a/openwisp_controller/geo/tests/test_api.py +++ b/openwisp_controller/geo/tests/test_api.py @@ -1,9 +1,11 @@ import json import tempfile +from io import BytesIO from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission from django.contrib.gis.geos import Point +from django.core.files.uploadedfile import InMemoryUploadedFile from django.test import TestCase from django.test.client import BOUNDARY, MULTIPART_CONTENT, encode_multipart from django.urls import reverse @@ -27,7 +29,7 @@ User = get_user_model() -class TestApi(TestGeoMixin, TestCase): +class TestApi(AssertNumQueriesSubTestMixin, TestGeoMixin, TestCase): url_name = 'geo_api:device_location' object_location_model = DeviceLocation location_model = Location @@ -47,7 +49,7 @@ def test_permission_403(self): dl = self._create_object_location() url = reverse(self.url_name, args=[dl.device.pk]) r = self.client.get(url) - self.assertEqual(r.status_code, 403) + self.assertEqual(r.status_code, 401) def test_method_not_allowed(self): device = self._create_object() @@ -65,9 +67,18 @@ def test_get_existing_location(self): self.assertDictEqual( r.json(), { - 'type': 'Feature', - 'geometry': json.loads(dl.location.geometry.geojson), - 'properties': {'name': dl.location.name}, + 'location': { + 'type': 'Feature', + 'geometry': json.loads(dl.location.geometry.geojson), + 'properties': { + 'type': 'outdoor', + 'is_mobile': False, + 'name': 'test-location', + 'address': 'Via del Corso, Roma, Italia', + }, + }, + 'floorplan': None, + 'indoor': None, }, ) self.assertEqual(self.location_model.objects.count(), 1) @@ -86,30 +97,32 @@ def test_get_create_location(self): device = self._create_object() url = reverse(self.url_name, args=[device.pk]) r = self.client.get(url, {'key': device.key}) - self.assertEqual(r.status_code, 200) - self.assertDictEqual( - r.json(), - {'type': 'Feature', 'geometry': None, 'properties': {'name': device.name}}, - ) - self.assertEqual(self.location_model.objects.count(), 1) + self.assertEqual(r.status_code, 404) - def test_put_update_coordinates(self): + def test_patch_update_coordinates(self): self.assertEqual(self.location_model.objects.count(), 0) dl = self._create_object_location() url = reverse(self.url_name, args=[dl.device.pk]) url = '{0}?key={1}'.format(url, dl.device.key) self.assertEqual(self.location_model.objects.count(), 1) coords = json.loads(Point(2, 23).geojson) - feature = json.dumps({'type': 'Feature', 'geometry': coords}) - r = self.client.put(url, feature, content_type='application/json') - self.assertEqual(r.status_code, 200) - self.assertDictEqual( - r.json(), - { + data = { + 'location': { 'type': 'Feature', 'geometry': coords, - 'properties': {'name': dl.location.name}, - }, + 'properties': { + 'type': 'outdoor', + 'is_mobile': False, + 'name': dl.location.name, + 'address': dl.location.address, + }, + } + } + with self.assertNumQueries(3): + r = self.client.patch(url, data, content_type='application/json') + self.assertEqual(r.status_code, 200) + self.assertEqual( + r.data['location']['geometry']['coordinates'], coords['coordinates'] ) self.assertEqual(self.location_model.objects.count(), 1) @@ -274,6 +287,22 @@ def _create_device_location(self, **kwargs): device_location.save() return device_location + def _get_in_memory_upload_file(self): + image = Image.new("RGB", (100, 100)) + with tempfile.NamedTemporaryFile(suffix=".png", mode="w+b") as tmp_file: + image.save(tmp_file, format="png") + tmp_file.seek(0) + byio = BytesIO(tmp_file.read()) + inm_file = InMemoryUploadedFile( + file=byio, + field_name="avatar", + name="testImage.png", + content_type="image/png", + size=byio.getbuffer().nbytes, + charset=None, + ) + return inm_file + def test_get_floorplan_list(self): path = reverse('geo_api:list_floorplan') with self.assertNumQueries(3): @@ -324,7 +353,7 @@ def test_put_floorplan_detail(self): image = Image.new('RGB', (100, 100)) image.save(temporary_image.name) data = {'floor': 12, 'image': temporary_image, 'location': l1.pk} - with self.assertNumQueries(12): + with self.assertNumQueries(10): response = self.client.put( path, encode_multipart(BOUNDARY, data), content_type=MULTIPART_CONTENT ) @@ -337,7 +366,7 @@ def test_patch_floorplan_detail(self): self.assertEqual(f1.floor, 1) path = reverse('geo_api:detail_floorplan', args=[f1.pk]) data = {'floor': 12} - with self.assertNumQueries(10): + with self.assertNumQueries(8): response = self.client.patch(path, data, content_type='application/json') self.assertEqual(response.status_code, 200) self.assertEqual(response.data['floor'], 12) @@ -365,7 +394,7 @@ def test_filter_location_list(self): self._create_org_user(user=staff_user, organization=org1, is_admin=True) self.client.force_login(staff_user) path = reverse('geo_api:list_location') - with self.assertNumQueries(6): + with self.assertNumQueries(7): response = self.client.get(path) self.assertEqual(response.status_code, 200) self.assertEqual(response.data['count'], 1) @@ -383,14 +412,14 @@ def test_post_location_list(self): 'address': 'Via del Corso, Roma, Italia', 'geometry': coords, } - with self.assertNumQueries(6): + with self.assertNumQueries(9): response = self.client.post(path, data, content_type='application/json') self.assertEqual(response.status_code, 201) def test_get_location_detail(self): l1 = self._create_location() path = reverse('geo_api:detail_location', args=[l1.pk]) - with self.assertNumQueries(3): + with self.assertNumQueries(4): response = self.client.get(path) self.assertEqual(response.status_code, 200) @@ -407,7 +436,7 @@ def test_put_location_detail(self): 'address': 'Via del Corso, Roma, Italia', 'geometry': coords, } - with self.assertNumQueries(7): + with self.assertNumQueries(6): response = self.client.put(path, data, content_type='application/json') self.assertEqual(response.status_code, 200) self.assertEqual(response.data['organization'], org1.pk) @@ -418,11 +447,52 @@ def test_patch_location_detail(self): self.assertEqual(l1.name, 'test-location') path = reverse('geo_api:detail_location', args=[l1.pk]) data = {'name': 'change-test-location'} - with self.assertNumQueries(6): + with self.assertNumQueries(5): response = self.client.patch(path, data, content_type='application/json') self.assertEqual(response.status_code, 200) self.assertEqual(response.data['name'], 'change-test-location') + def test_create_location_outdoor_with_floorplan(self): + path = reverse('geo_api:list_location') + coords = json.loads(Point(2, 23).geojson) + data = { + 'organization': self._get_org().pk, + 'name': 'test-location', + 'type': 'outdoor', + 'is_mobile': False, + 'address': 'Via del Corso, Roma, Italia', + 'geometry': coords, + 'floorplan': {'floor': 12}, + } + with self.assertNumQueries(3): + response = self.client.post(path, data, content_type='application/json') + self.assertEqual(response.status_code, 400) + self.assertIn( + "Floorplan can only be added with location of the type indoor", + str(response.content), + ) + + def test_patch_floorplan_detail_api(self): + l1 = self._create_location(type='indoor') + fl = self._create_floorplan(location=l1) + path = reverse('geo_api:detail_location', args=[l1.pk]) + data = {'floorplan': {'floor': 13}} + with self.assertNumQueries(13): + response = self.client.patch(path, data, content_type='application/json') + self.assertEqual(response.status_code, 200) + fl.refresh_from_db() + self.assertEqual(fl.floor, 13) + + def test_change_location_type_to_outdoor_api(self): + l1 = self._create_location(type='indoor') + self._create_floorplan(location=l1) + path = reverse('geo_api:detail_location', args=[l1.pk]) + data = {'type': 'outdoor'} + with self.assertNumQueries(10): + response = self.client.patch(path, data, content_type='application/json') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['floorplan'], []) + def test_delete_location_detail(self): l1 = self._create_location() path = reverse('geo_api:detail_location', args=[l1.pk]) @@ -430,71 +500,371 @@ def test_delete_location_detail(self): response = self.client.delete(path) self.assertEqual(response.status_code, 204) - def test_get_devicelocation_list(self): - device_a = self._create_device(organization=self._get_org()) - location_a = self._create_location(organization=self._get_org()) - self._create_device_location(content_object=device_a, location=location_a) - path = reverse('geo_api:device_location_list') - with self.assertNumQueries(4): - response = self.client.get(path) + def test_device_location_with_outdoor_api(self): + device = self._create_device() + url = reverse('geo_api:device_location', args=[device.pk]) + path = '{0}?key={1}'.format(url, device.key) + org1 = device.organization + l1 = self._create_location( + name='location1org', type='indoor', organization=org1 + ) + fl = self._create_floorplan(floor=13, location=l1) + dl = self._create_device_location( + content_object=device, floorplan=fl, location=l1, indoor="123.1, 32" + ) + self.assertEqual(dl.floorplan, fl) + data = {'location': {'type': 'Feature', 'properties': {'type': 'outdoor'}}} + with self.assertNumQueries(5): + response = self.client.patch(path, data, content_type='application/json') self.assertEqual(response.status_code, 200) - self.assertEqual(response.data['count'], 1) + self.assertEqual(response.data['location']['properties']['type'], 'outdoor') + self.assertIsNone(response.data['floorplan']) + dl.refresh_from_db() + self.assertEqual(dl.location.type, 'outdoor') + self.assertIsNone(dl.floorplan) + + def test_device_location_floorplan_update(self): + device = self._create_device() + url = reverse('geo_api:device_location', args=[device.pk]) + path = '{0}?key={1}'.format(url, device.key) + org1 = device.organization + l1 = self._create_location( + name='location1org', type='indoor', organization=org1 + ) + fl = self._create_floorplan(floor=13, location=l1) + self._create_device_location( + content_object=device, floorplan=fl, location=l1, indoor="123.1, 32" + ) + data = {'floorplan': {'floor': 31}} + with self.assertNumQueries(11): + response = self.client.patch(path, data, content_type='application/json') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['floorplan']['floor'], 31) + fl.refresh_from_db() + self.assertEqual(fl.floor, 31) + + def test_patch_update_coordinates_of_device_api(self): + device = self._create_device() + url = reverse('geo_api:device_location', args=[device.pk]) + path = '{0}?key={1}'.format(url, device.key) + org1 = device.organization + l1 = self._create_location( + name='location1org', type='outdoor', organization=org1 + ) + self._create_device_location(content_object=device, location=l1) + self.assertEqual(l1.geometry.coords, (12.512124, 41.898903)) + data = { + 'location': { + 'geometry': {'type': 'Point', 'coordinates': [13.512124, 42.898903]} + } + } + with self.assertNumQueries(5): + response = self.client.patch(path, data, content_type='application/json') + self.assertEqual(response.status_code, 200) + l1.refresh_from_db() + self.assertEqual(l1.geometry.coords, (13.512124, 42.898903)) + + def test_put_to_change_full_location_detail_api(self): + device = self._create_device() + url = reverse('geo_api:device_location', args=[device.pk]) + path = '{0}?key={1}'.format(url, device.key) + org1 = device.organization + l1 = self._create_location( + name='location1org', type='outdoor', organization=org1 + ) + self._create_device_location(content_object=device, location=l1) + coords1 = l1.geometry.coords + self.assertEqual(coords1, (12.512124, 41.898903)) + self.assertEqual(l1.name, 'location1org') + self.assertEqual(l1.address, 'Via del Corso, Roma, Italia') + data = { + 'location': { + 'type': 'Feature', + 'geometry': {'type': 'Point', 'coordinates': [13.51, 51.89]}, + 'properties': { + 'type': 'outdoor', + 'is_mobile': False, + 'name': 'GSoC21', + 'address': 'Change Via del Corso, Roma, Italia', + }, + } + } + with self.assertNumQueries(5): + response = self.client.put(path, data, content_type='application/json') + self.assertEqual(response.status_code, 200) + l1.refresh_from_db() + self.assertNotEqual(coords1, l1.geometry.coords) + self.assertEqual(l1.geometry.coords, (13.51, 51.89)) + self.assertEqual(l1.name, 'GSoC21') + self.assertEqual(l1.address, 'Change Via del Corso, Roma, Italia') - def test_post_devicelocation_list(self): - device_1 = self._create_device(organization=self._get_org()) - location_1 = self._create_location(organization=self._get_org()) - path = reverse('geo_api:device_location_list') + def test_create_location_with_floorplan(self): + path = reverse('geo_api:list_location') + fl_image = self._get_in_memory_upload_file() + coords = json.loads(Point(2, 23).geojson) data = { - 'indoor': None, - 'content_object': device_1.pk, - 'location': location_1.pk, - 'floorplan': None, + 'organization': self._get_org().pk, + 'name': 'GSoC21', + 'type': 'indoor', + 'is_mobile': False, + 'address': 'Via del Corso, Roma, Italia', + 'geometry': [coords], + 'floorplan.floor': ['23'], + 'floorplan.image': [fl_image], } - with self.assertNumQueries(10): - response = self.client.post(path, data, content_type='application/json') + with self.assertNumQueries(16): + response = self.client.post(path, data, format='multipart') self.assertEqual(response.status_code, 201) + self.assertEqual(Location.objects.count(), 1) + self.assertEqual(FloorPlan.objects.count(), 1) - def test_get_devicelocation_detail(self): - device_1 = self._create_device(organization=self._get_org()) - location_1 = self._create_location(organization=self._get_org()) - dl = self._create_device_location(content_object=device_1, location=location_1) - path = reverse('geo_api:device_location_detail', args=[dl.pk]) + def test_create_new_floorplan_with_put_location_api(self): + org1 = self._get_org() + l1 = self._create_location( + name='location1org', type='outdoor', organization=org1 + ) + path = reverse('geo_api:detail_location', args=(l1.pk,)) + coords = json.loads(Point(2, 23).geojson) + fl_image = self._get_in_memory_upload_file() + data = { + 'organization': self._get_org().pk, + 'name': 'GSoC21', + 'type': 'indoor', + 'is_mobile': False, + 'address': 'Via del Corso, Roma, Italia', + 'geometry': [coords], + 'floorplan.floor': '23', + 'floorplan.image': fl_image, + } + with self.assertNumQueries(16): + response = self.client.put( + path, encode_multipart(BOUNDARY, data), content_type=MULTIPART_CONTENT + ) + self.assertEqual(response.status_code, 200) + + def test_device_location_unauth_no_key(self): + device = self._create_device() + path = reverse('geo_api:device_location', args=(device.pk,)) + self.client.logout() + with self.assertNumQueries(1): + response = self.client.get(path) + self.assertEqual(response.status_code, 401) + + def test_device_location_auth_access_own_org_data_with_no_key(self): + org1 = self._create_org(name='org1') + device = self._create_device(name='00:12:23:34:45:56', organization=org1) + staff_user = self._get_operator() + device_perm = Permission.objects.filter(codename__endswith='device') + staff_user.user_permissions.add(*device_perm) + self._create_org_user(user=staff_user, organization=org1, is_admin=True) + self.client.force_login(staff_user) + path = reverse('geo_api:device_location', args=(device.pk,)) + l1 = self._create_location( + name='location1org', type='outdoor', organization=org1 + ) + self._create_device_location(content_object=device, location=l1) + with self.assertNumQueries(5): + response = self.client.get(path) + self.assertEqual(response.status_code, 200) + + def test_device_location_auth_access_different_org_data_with_no_key(self): + org2 = self._create_org(name='org2') + org1 = self._create_org(name='org1') + device2 = self._create_device(name='00:11:22:33:44:66', organization=org2) + staff_user = self._get_operator() + device_perm = Permission.objects.filter(codename__endswith='device') + staff_user.user_permissions.add(*device_perm) + self._create_org_user(user=staff_user, organization=org1, is_admin=True) + self.client.force_login(staff_user) + path = reverse('geo_api:device_location', args=(device2.pk,)) + with self.assertNumQueries(5): + response = self.client.get(path) + self.assertEqual(response.status_code, 404) + + def test_device_location_auth_access_different_org_data_with_key(self): + org1 = self._create_org(name='org1') + org2 = self._create_org(name='org2') + device2 = self._create_device(name='00:11:22:33:44:66', organization=org2) + staff_user = self._get_operator() + device_perm = Permission.objects.filter(codename__endswith='device') + staff_user.user_permissions.add(*device_perm) + self._create_org_user(user=staff_user, organization=org1, is_admin=True) + self.client.force_login(staff_user) + url = reverse('geo_api:device_location', args=(device2.pk,)) + path = '{0}?key={1}'.format(url, device2.key) + l1 = self._create_location( + name='location1org', type='outdoor', organization=org2 + ) + self._create_device_location(content_object=device2, location=l1) with self.assertNumQueries(3): response = self.client.get(path) self.assertEqual(response.status_code, 200) - def test_put_devicelocation_detail(self): - device_1 = self._create_device(organization=self._get_org()) - location_1 = self._create_location(organization=self._get_org()) - dl = self._create_device_location(content_object=device_1, location=location_1) - path = reverse('geo_api:device_location_detail', args=[dl.pk]) + def test_device_location_unauth_with_correct_key(self): + device = self._create_device() + l1 = self._create_location( + name='location1org', type='outdoor', organization=device.organization + ) + self._create_device_location(content_object=device, location=l1) + url = reverse('geo_api:device_location', args=(device.pk,)) + path = '{0}?key={1}'.format(url, device.key) + self.client.logout() + with self.assertNumQueries(1): + response = self.client.get(path) + self.assertEqual(response.status_code, 200) + + def test_device_location_unauth_with_wrong_key(self): + device = self._create_device() + url = reverse('geo_api:device_location', args=(device.pk,)) + path = '{0}?key={1}'.format(url, 12345) + self.client.logout() + with self.assertNumQueries(1): + response = self.client.get(path) + self.assertEqual(response.status_code, 401) + + def test_update_device_location_to_indoor_api(self): + org1 = self._create_org(name='org1') + staff_user = self._get_operator() + device_perm = Permission.objects.filter(codename__endswith='device') + staff_user.user_permissions.add(*device_perm) + self._create_org_user(user=staff_user, organization=org1, is_admin=True) + self.client.force_login(staff_user) + device = self._create_device(name='00:22:23:34:45:56', organization=org1) + l1 = self._create_location( + name='location1org', type='outdoor', organization=org1 + ) + self._create_device_location(content_object=device, location=l1) + path = reverse('geo_api:device_location', args=[device.pk]) + coords = json.loads(Point(2, 23).geojson) + fl_image = self._get_in_memory_upload_file() + data = { + 'location.type': 'indoor', + 'location.name': 'GSoC21', + 'location.address': 'OpenWISP', + 'location.geometry': [coords], + 'floorplan.floor': ['21'], + 'indoor': ['12.342,23.541'], + 'floorplan.image': [fl_image], + } + with self.assertNumQueries(11): + response = self.client.put( + path, encode_multipart(BOUNDARY, data), content_type=MULTIPART_CONTENT + ) + self.assertEqual(response.status_code, 200) + + def test_put_device_location_in_json_form(self): + org1 = self._create_org(name='org1') + staff_user = self._get_operator() + device_perm = Permission.objects.filter(codename__endswith='device') + staff_user.user_permissions.add(*device_perm) + self._create_org_user(user=staff_user, organization=org1, is_admin=True) + self.client.force_login(staff_user) + device = self._create_device(name='00:22:23:34:45:56', organization=org1) + l1 = self._create_location( + name='location1org', type='indoor', organization=org1 + ) + fl = self._create_floorplan(floor=13, location=l1) + self._create_device_location( + content_object=device, floorplan=fl, location=l1, indoor="123.1, 32" + ) data = { - 'indoor': None, - 'content_object': device_1.pk, - 'location': location_1.pk, - 'floorplan': None, + 'location': { + 'type': 'Feature', + 'geometry': {'type': 'Point', 'coordinates': [12.3456, 20.345]}, + 'properties': { + 'type': 'indoor', + 'is_mobile': False, + 'name': 'GSoC21', + 'address': 'OpenWISP', + }, + }, + 'floorplan': {'floor': 37, 'image': 'http://url-of-the-image'}, + 'indoor': '10.332,10.3223', } - with self.assertNumQueries(12): + path = reverse('geo_api:device_location', args=[device.pk]) + with self.assertNumQueries(14): response = self.client.put(path, data, content_type='application/json') self.assertEqual(response.status_code, 200) - def test_patch_devicelocation_detail(self): - device_1 = self._create_device(organization=self._get_org()) - location_1 = self._create_location(organization=self._get_org()) - dl = self._create_device_location(content_object=device_1, location=location_1) - path = reverse('geo_api:device_location_detail', args=[dl.pk]) - location_2 = self._create_location(organization=self._get_org()) - data = {'location': location_2.pk} - with self.assertNumQueries(10): - response = self.client.patch(path, data, content_type='application/json') + def test_create_device_location_with_put_api(self): + org1 = self._create_org(name='org1') + staff_user = self._get_operator() + device_perm = Permission.objects.filter(codename__endswith='device') + staff_user.user_permissions.add(*device_perm) + self._create_org_user(user=staff_user, organization=org1, is_admin=True) + self.client.force_login(staff_user) + device = self._create_device(name='00:22:23:34:45:56', organization=org1) + path = reverse('geo_api:device_location', args=[device.pk]) + data = { + 'location': { + 'type': 'Feature', + 'geometry': {'type': 'Point', 'coordinates': [12.3456, 20.345]}, + 'properties': { + 'type': 'outdoor', + 'is_mobile': False, + 'name': 'GSoC21', + 'address': 'OpenWISP', + }, + }, + } + self.assertEqual(DeviceLocation.objects.count(), 0) + with self.assertNumQueries(16): + response = self.client.put(path, data, content_type='application/json') self.assertEqual(response.status_code, 200) - self.assertEqual(response.data['location'], location_2.pk) + self.assertEqual(DeviceLocation.objects.count(), 1) - def test_delete_devicelocation_detail(self): - device_a = self._create_device(organization=self._get_org()) - location_a = self._create_location(organization=self._get_org()) - dl = self._create_device_location(content_object=device_a, location=location_a) - path = reverse('geo_api:device_location_detail', args=[dl.pk]) - with self.assertNumQueries(6): - response = self.client.delete(path) - self.assertEqual(response.status_code, 204) + def test_create_device_location_with_floorplan_with_put_api(self): + org1 = self._create_org(name='org1') + staff_user = self._get_operator() + device_perm = Permission.objects.filter(codename__endswith='device') + staff_user.user_permissions.add(*device_perm) + self._create_org_user(user=staff_user, organization=org1, is_admin=True) + self.client.force_login(staff_user) + device = self._create_device(name='00:22:23:34:45:56', organization=org1) + path = reverse('geo_api:device_location', args=[device.pk]) + coords = json.loads(Point(2, 23).geojson) + fl_image = self._get_in_memory_upload_file() + data = { + 'location.type': 'indoor', + 'location.name': 'GSoC21', + 'location.address': 'OpenWISP', + 'location.geometry': [coords], + 'floorplan.floor': ['21'], + 'indoor': ['12.342,23.541'], + 'floorplan.image': [fl_image], + } + self.assertEqual(DeviceLocation.objects.count(), 0) + with self.assertNumQueries(19): + response = self.client.put( + path, encode_multipart(BOUNDARY, data), content_type=MULTIPART_CONTENT + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(DeviceLocation.objects.count(), 1) + + def test_create_outdoor_device_location_with_floorplan_put_api(self): + org1 = self._create_org(name='org1') + staff_user = self._get_operator() + device_perm = Permission.objects.filter(codename__endswith='device') + staff_user.user_permissions.add(*device_perm) + self._create_org_user(user=staff_user, organization=org1, is_admin=True) + self.client.force_login(staff_user) + device = self._create_device(name='00:22:23:34:45:56', organization=org1) + path = reverse('geo_api:device_location', args=[device.pk]) + coords = json.loads(Point(2, 23).geojson) + fl_image = self._get_in_memory_upload_file() + data = { + 'location.type': 'outdoor', + 'location.name': 'GSoC21', + 'location.address': 'OpenWISP', + 'location.geometry': [coords], + 'floorplan.floor': ['21'], + 'indoor': ['12.342,23.541'], + 'floorplan.image': [fl_image], + } + self.assertEqual(DeviceLocation.objects.count(), 0) + with self.assertNumQueries(16): + response = self.client.put( + path, encode_multipart(BOUNDARY, data), content_type=MULTIPART_CONTENT + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(DeviceLocation.objects.count(), 1) diff --git a/openwisp_controller/geo/utils.py b/openwisp_controller/geo/utils.py index f6e3a3965..91571f409 100644 --- a/openwisp_controller/geo/utils.py +++ b/openwisp_controller/geo/utils.py @@ -18,26 +18,22 @@ def get_geo_urls(geo_views): geo_views.location_device_list, name='location_device_list', ), - path('api/v1/geo/floorplan/', geo_views.list_floorplan, name='list_floorplan'), path( - 'api/v1/geo/floorplan//', - geo_views.detail_floorplan, - name='detail_floorplan', + 'api/v1/controller/floorplan/', + geo_views.list_floorplan, + name='list_floorplan', ), - path('api/v1/geo/location/', geo_views.list_location, name='list_location'), path( - 'api/v1/geo/location//', - geo_views.detail_location, - name='detail_location', + 'api/v1/controller/floorplan//', + geo_views.detail_floorplan, + name='detail_floorplan', ), path( - 'api/v1/geo/devicelocation/', - geo_views.device_location_list, - name='device_location_list', + 'api/v1/controller/location/', geo_views.list_location, name='list_location' ), path( - 'api/v1/geo/devicelocation//', - geo_views.device_location_detail, - name='device_location_detail', + 'api/v1/controller/location//', + geo_views.detail_location, + name='detail_location', ), ] diff --git a/tests/openwisp2/sample_geo/views.py b/tests/openwisp2/sample_geo/views.py index 28480687d..71a34f1b4 100644 --- a/tests/openwisp2/sample_geo/views.py +++ b/tests/openwisp2/sample_geo/views.py @@ -1,9 +1,3 @@ -from openwisp_controller.geo.api.views import ( - DeviceLocationDetailView as BaseDeviceLocationDetailView, -) -from openwisp_controller.geo.api.views import ( - DeviceLocationListCreateView as BaseDeviceLocationListCreateView, -) from openwisp_controller.geo.api.views import ( DeviceLocationView as BaseDeviceLocationView, ) @@ -55,14 +49,6 @@ class LocationDetailView(BaseLocationDetailView): pass -class DeviceLocationListCreateView(BaseDeviceLocationListCreateView): - pass - - -class DeviceLocationDetailView(BaseDeviceLocationDetailView): - pass - - device_location = DeviceLocationView.as_view() geojson = GeoJsonLocationList.as_view() location_device_list = LocationDeviceList.as_view() @@ -70,5 +56,3 @@ class DeviceLocationDetailView(BaseDeviceLocationDetailView): detail_floorplan = FloorPlanDetailView.as_view() list_location = LocationListCreateView.as_view() detail_location = LocationDetailView.as_view() -device_location_list = DeviceLocationListCreateView.as_view() -device_location_detail = DeviceLocationDetailView.as_view()