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