diff --git a/docs/user/rest-api.rst b/docs/user/rest-api.rst index b1cefc573..b9a7d17c3 100644 --- a/docs/user/rest-api.rst +++ b/docs/user/rest-api.rst @@ -956,6 +956,29 @@ Delete Floor Plan DELETE /api/v1/controller/floorplan/{pk}/ +List Indoor Coordinates of a Location +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. note:: + + This endpoint returns device coordinates from the first floor above + ground (lowest non-negative floors) by default. If a location only has + negative floors (e.g. underground parking lot), then it will return + the closest floor to the ground (greatest negative floor). + +.. code-block:: text + + GET /api/v1/controller/location/{id}/indoor-coordinates/ + +**Available filters** + +You can filter using ``floor`` to get list of devices and their indoor +coodinates for that floor. + +.. code-block:: text + + GET /api/v1/controller/location/{id}/indoor-coordinates/?floor={floor} + List Templates ~~~~~~~~~~~~~~ diff --git a/openwisp_controller/geo/api/serializers.py b/openwisp_controller/geo/api/serializers.py index 742542049..909810c8b 100644 --- a/openwisp_controller/geo/api/serializers.py +++ b/openwisp_controller/geo/api/serializers.py @@ -348,3 +348,48 @@ def update(self, instance, validated_data): ) validated_data = self._validate(validated_data) return super().update(instance, validated_data) + + +class IndoorCoordinatesSerializer(serializers.ModelSerializer): + admin_edit_url = SerializerMethodField("get_admin_edit_url") + device_id = serializers.UUIDField(source="content_object.id", read_only=True) + floorplan_id = serializers.UUIDField(source="floorplan.id", read_only=True) + device_name = serializers.CharField(source="content_object.name") + mac_address = serializers.CharField(source="content_object.mac_address") + floor_name = serializers.SerializerMethodField() + floor = serializers.IntegerField(source="floorplan.floor") + image = serializers.ImageField(source="floorplan.image", read_only=True) + coordinates = serializers.SerializerMethodField() + + class Meta: + model = DeviceLocation + fields = [ + "id", + "admin_edit_url", + "device_id", + "floorplan_id", + "device_name", + "mac_address", + "floor_name", + "floor", + "image", + "coordinates", + ] + + def get_admin_edit_url(self, obj): + return self.context["request"].build_absolute_uri( + reverse( + f"admin:{obj.content_object._meta.app_label}_device_change", + args=(obj.content_object.id,), + ) + ) + + def get_floor_name(self, obj): + return str(obj.floorplan) + + def get_coordinates(self, obj): + """ + NetJsonGraph expects indoor coordinates in {'lat': y, 'lng': x}. + """ + y, x = obj.indoor.split(",", 1) + return {"lat": float(y), "lng": float(x)} diff --git a/openwisp_controller/geo/api/views.py b/openwisp_controller/geo/api/views.py index b5514cf26..e05e6893a 100644 --- a/openwisp_controller/geo/api/views.py +++ b/openwisp_controller/geo/api/views.py @@ -1,6 +1,7 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.db.models import Count from django.http import Http404 +from django.utils.translation import gettext_lazy as _ from django_filters import rest_framework as filters from rest_framework import generics, pagination, status from rest_framework.exceptions import NotFound, PermissionDenied @@ -14,13 +15,18 @@ from openwisp_users.api.filters import OrganizationManagedFilter from openwisp_users.api.mixins import FilterByOrganizationManaged, FilterByParentManaged -from ...mixins import ProtectedAPIMixin, RelatedDeviceProtectedAPIMixin +from ...mixins import ( + BaseProtectedAPIMixin, + ProtectedAPIMixin, + RelatedDeviceProtectedAPIMixin, +) from .filters import DeviceListFilter from .serializers import ( DeviceCoordinatesSerializer, DeviceLocationSerializer, FloorPlanSerializer, GeoJsonLocationSerializer, + IndoorCoordinatesSerializer, LocationDeviceSerializer, LocationSerializer, ) @@ -51,6 +57,37 @@ class Meta(OrganizationManagedFilter.Meta): model = FloorPlan +class IndoorCoordinatesFilter(filters.FilterSet): + floor = filters.NumberFilter(label=_("Floor"), method="filter_by_floor") + + @property + def qs(self): + qs = super().qs + if "floor" not in self.data: + qs = self.filter_by_floor(qs, "floor", None) + return qs + + def filter_by_floor(self, queryset, name, value): + """ + If no floor parameter is provided: + - Return data for the first available non-negative floor. + - If no non-negative floor exists, return data for the maximum negative floor. + """ + if value is not None: + return queryset.filter(floorplan__floor=value) + # No floor parameter provided + floors = list(queryset.values_list("floorplan__floor", flat=True).distinct()) + if not floors: + return queryset.none() + non_negative_floors = [f for f in floors if f >= 0] + default_floor = min(non_negative_floors) if non_negative_floors else max(floors) + return queryset.filter(floorplan__floor=default_floor) + + class Meta(OrganizationManagedFilter.Meta): + model = DeviceLocation + fields = ["floor"] + + class ListViewPagination(pagination.PageNumberPagination): page_size = 10 page_size_query_param = "page_size" @@ -186,6 +223,47 @@ class GeoJsonLocationList( filterset_class = LocationOrganizationFilter +class IndoorCoodinatesViewPagination(ListViewPagination): + page_size = 50 + + +class IndoorCoordinatesList( + FilterByParentManaged, BaseProtectedAPIMixin, generics.ListAPIView +): + serializer_class = IndoorCoordinatesSerializer + filter_backends = [filters.DjangoFilterBackend] + filterset_class = IndoorCoordinatesFilter + pagination_class = IndoorCoodinatesViewPagination + queryset = ( + DeviceLocation.objects.filter( + location__type="indoor", + floorplan__isnull=False, + ) + .select_related( + "content_object", "location", "floorplan", "location__organization" + ) + .order_by("floorplan__floor") + ) + + def get_parent_queryset(self): + qs = Location.objects.filter(pk=self.kwargs["pk"]) + return qs + + def get_queryset(self): + return super().get_queryset().filter(location_id=self.kwargs["pk"]) + + def get_available_floors(self, qs): + floors = list(qs.values_list("floorplan__floor", flat=True).distinct()) + return floors + + def list(self, request, *args, **kwargs): + floors = self.get_available_floors(self.get_queryset()) + response = super().list(request, *args, **kwargs) + if response.status_code == 200: + response.data["floors"] = floors + return response + + class LocationDeviceList( FilterByParentManaged, ProtectedAPIMixin, generics.ListAPIView ): @@ -202,6 +280,17 @@ def get_queryset(self): qs = Device.objects.filter(devicelocation__location_id=self.kwargs["pk"]) return qs + def get_has_floorplan(self, qs): + qs = qs.filter(devicelocation__floorplan__isnull=False).exists() + return qs + + def list(self, request, *args, **kwargs): + has_floorplan = self.get_has_floorplan(self.get_queryset()) + response = super().list(self, request, *args, **kwargs) + if response.status_code == 200: + response.data["has_floorplan"] = has_floorplan + return response + class FloorPlanListCreateView(ProtectedAPIMixin, generics.ListCreateAPIView): serializer_class = FloorPlanSerializer @@ -244,5 +333,6 @@ class LocationDetailView( location_device_list = LocationDeviceList.as_view() list_floorplan = FloorPlanListCreateView.as_view() detail_floorplan = FloorPlanDetailView.as_view() +indoor_coordinates_list = IndoorCoordinatesList.as_view() list_location = LocationListCreateView.as_view() detail_location = LocationDetailView.as_view() diff --git a/openwisp_controller/geo/tests/test_api.py b/openwisp_controller/geo/tests/test_api.py index 4cc37519f..742ceb072 100644 --- a/openwisp_controller/geo/tests/test_api.py +++ b/openwisp_controller/geo/tests/test_api.py @@ -35,6 +35,7 @@ class TestApi(TestGeoMixin, TestCase): object_location_model = DeviceLocation location_model = Location object_model = Device + floorplan_model = FloorPlan def test_permission_404(self): url = reverse(self.url_name, args=[self.object_model().pk]) @@ -130,7 +131,17 @@ def test_bearer_authentication(self): username="admin", password="password", is_staff=True, is_superuser=True ) token = Token.objects.create(user=user).key - device = self._create_object_location().device + device = self._create_object() + location = self._create_location( + organization=device.organization, type="indoor" + ) + floor = self._create_floorplan(floor=1, location=location) + self._create_object_location( + content_object=device, + location=location, + floorplan=floor, + organization=device.organization, + ) with self.subTest("Test DeviceLocationView"): response = self.client.get( @@ -150,7 +161,6 @@ def test_bearer_authentication(self): self.assertEqual(response.status_code, 200) with self.subTest("Test LocationDeviceList"): - location = self._create_location(organization=device.organization) response = self.client.get( reverse("geo_api:location_device_list", args=[location.id]), content_type="application/json", @@ -158,6 +168,14 @@ def test_bearer_authentication(self): ) self.assertEqual(response.status_code, 200) + with self.subTest("Test IndoorCoordinatesList"): + response = self.client.get( + reverse("geo_api:indoor_coordinates_list", args=[location.id]), + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + self.assertEqual(response.status_code, 200) + def test_deactivated_device(self): device = self._create_object_location().device url = "{0}?key={1}".format(reverse(self.url_name, args=[device.pk]), device.key) @@ -182,6 +200,7 @@ class TestMultitenantApi(TestGeoMixin, TestCase, CreateConfigTemplateMixin): object_location_model = DeviceLocation location_model = Location object_model = Device + floorplan_model = FloorPlan def setUp(self): super().setUp() @@ -285,6 +304,86 @@ def test_geojson_list(self): r = self.client.get(reverse(url)) self.assertEqual(r.status_code, 401) + def test_indoor_coodinate_list(self): + url = "geo_api:indoor_coordinates_list" + org_a = self._get_org("org_a") + org_b = self._get_org("org_b") + device_a = self._create_device(organization=org_a) + device_b = self._create_device(organization=org_b) + location_a = self._create_location(type="indoor", organization=org_a) + location_b = self._create_location(type="indoor", organization=org_b) + floor_a = self._create_floorplan(location=location_a) + floor_b = self._create_floorplan(location=location_b) + self._create_object_location( + content_object=device_a, + location=location_a, + floorplan=floor_a, + organization=org_a, + ) + device_location_b = self._create_object_location( + content_object=device_b, + location=location_b, + floorplan=floor_b, + organization=org_b, + ) + + with self.subTest("Test indoor coordinate list for org operator"): + self.client.login(username="operator", password="tester") + r = self.client.get(reverse(url, args=[location_a.id])) + self.assertContains(r, str(device_a.id)) + r = self.client.get(reverse(url, args=[location_b.id])) + self.assertEqual(r.status_code, 404) + + with self.subTest("Test indoor coordinate list for superuser"): + self.client.login(username="admin", password="tester") + r = self.client.get(reverse(url, args=[location_a.id])) + self.assertContains(r, str(device_a.id)) + r = self.client.get(reverse(url, args=[location_b.id])) + self.assertContains(r, str(device_b.id)) + + with self.subTest("Test indoor coordinate list for org administrator"): + administrator = self._create_administrator(organizations=[org_a, org_b]) + self.client.force_login(administrator) + r = self.client.get(reverse(url, args=[location_a.id])) + self.assertEqual(r.status_code, 200) + self.assertContains(r, str(device_a.id)) + r = self.client.get(reverse(url, args=[location_b.id])) + self.assertEqual(r.status_code, 200) + # Verify all fields in the response + self.assertEqual(r.data["count"], 1) + self.assertIsNone(r.data["next"]) + self.assertIsNone(r.data["previous"]) + self.assertEqual(len(r.data["results"]), 1) + self.assertEqual(r.data["floors"], [floor_b.floor]) + indoor_coordinate = r.data["results"][0] + self.assertEqual(indoor_coordinate["id"], str(device_location_b.id)) + self.assertEqual(indoor_coordinate["device_id"], str(device_b.id)) + self.assertEqual(indoor_coordinate["floorplan_id"], str(floor_b.id)) + self.assertEqual(indoor_coordinate["device_name"], device_b.name) + self.assertEqual(indoor_coordinate["mac_address"], device_b.mac_address) + self.assertEqual(indoor_coordinate["floor_name"], str(floor_b)) + self.assertEqual(indoor_coordinate["floor"], floor_b.floor) + self.assertEqual( + indoor_coordinate["admin_edit_url"], + "http://testserver{}".format( + reverse( + f"admin:{Device._meta.app_label}_device_change", + args=(device_b.id,), + ) + ), + ) + self.assertEqual( + indoor_coordinate["image"], f"http://testserver{floor_b.image.url}" + ) + self.assertEqual( + indoor_coordinate["coordinates"], {"lat": -140.3862, "lng": 40.369227} + ) + + with self.subTest("Test for unauthenticated user"): + self.client.logout() + response = self.client.get(reverse(url, args=[location_a.id])) + self.assertEqual(response.status_code, 401) + class TestGeoApi( AssertNumQueriesSubTestMixin, @@ -1036,3 +1135,157 @@ def test_deactivated_device(self): with self.subTest("Test deleting DeviceLocation"): response = self.client.delete(url) self.assertEqual(response.status_code, 403) + + def test_indoor_coordinates_list_api(self): + org = self._create_org(name="Test org") + location = self._create_location(type="indoor", organization=org) + floor1 = self._create_floorplan(floor=1, location=location) + floor2 = self._create_floorplan(floor=2, location=location) + device1 = self._create_device( + name="device1", mac_address="00:00:00:00:00:01", organization=org + ) + device2 = self._create_device( + name="device2", mac_address="00:00:00:00:00:02", organization=org + ) + self._create_object_location( + content_object=device1, + location=location, + floorplan=floor1, + organization=org, + ) + self._create_object_location( + content_object=device2, + location=location, + floorplan=floor2, + organization=org, + ) + path = reverse("geo_api:indoor_coordinates_list", args=[location.id]) + response = self.client.get(path) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["results"]), 1) + self.assertEqual(response.data["results"][0]["device_name"], "device1") + self.assertEqual(response.data["results"][0]["floor"], 1) + + with self.subTest("Test filter by floor"): + response = self.client.get(f"{path}?floor=2") + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["results"]), 1) + self.assertEqual(response.data["results"][0]["device_name"], "device2") + self.assertEqual(response.data["results"][0]["floor"], 2) + + with self.subTest("Test default floor with all positve floor"): + location2 = self._create_location(type="indoor", organization=org) + floor0 = self._create_floorplan(floor=0, location=location2) + floor5 = self._create_floorplan(floor=5, location=location2) + floor9 = self._create_floorplan(floor=9, location=location2) + device0 = self._create_device( + name="device", mac_address="00:00:00:00:00:00", organization=org + ) + device5 = self._create_device( + name="device5", mac_address="00:00:00:00:00:05", organization=org + ) + device9 = self._create_device( + name="device9", mac_address="00:00:00:00:00:09", organization=org + ) + self._create_object_location( + content_object=device0, + location=location2, + floorplan=floor0, + organization=org, + ) + self._create_object_location( + content_object=device5, + location=location2, + floorplan=floor5, + organization=org, + ) + self._create_object_location( + content_object=device9, + location=location2, + floorplan=floor9, + organization=org, + ) + path = reverse("geo_api:indoor_coordinates_list", args=[location2.id]) + response = self.client.get(path) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["results"]), 1) + self.assertEqual(response.data["results"][0]["device_name"], "device") + self.assertEqual(response.data["results"][0]["floor"], 0) + + with self.subTest("Test default floor with all negative floor"): + location3 = self._create_location(type="indoor", organization=org) + floor_1 = self._create_floorplan(floor=-1, location=location3) + floor_2 = self._create_floorplan(floor=-2, location=location3) + floor_3 = self._create_floorplan(floor=-3, location=location3) + device_1 = self._create_device( + name="device-1", mac_address="00:00:00:00:10:01", organization=org + ) + device_2 = self._create_device( + name="device-2", mac_address="00:00:00:00:10:02", organization=org + ) + device_3 = self._create_device( + name="device-3", mac_address="00:00:00:00:10:03", organization=org + ) + self._create_object_location( + content_object=device_1, + location=location3, + floorplan=floor_1, + organization=org, + ) + self._create_object_location( + content_object=device_2, + location=location3, + floorplan=floor_2, + organization=org, + ) + self._create_object_location( + content_object=device_3, + location=location3, + floorplan=floor_3, + organization=org, + ) + path = reverse("geo_api:indoor_coordinates_list", args=[location3.id]) + response = self.client.get(path) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["results"]), 1) + self.assertEqual(response.data["results"][0]["device_name"], "device-1") + self.assertEqual(response.data["results"][0]["floor"], -1) + + with self.subTest("Test default floor with positive and negative floor"): + location4 = self._create_location(type="indoor", organization=org) + floor_4 = self._create_floorplan(floor=-4, location=location4) + floor0 = self._create_floorplan(floor=0, location=location4) + floor22 = self._create_floorplan(floor=22, location=location4) + device_3 = self._create_device( + name="device-4", mac_address="00:00:00:10:10:03", organization=org + ) + device0 = self._create_device( + name="device-0", mac_address="00:00:00:00:10:00", organization=org + ) + device22 = self._create_device( + name="device22", mac_address="00:00:00:00:10:22", organization=org + ) + self._create_object_location( + content_object=device_3, + location=location4, + floorplan=floor_4, + organization=org, + ) + self._create_object_location( + content_object=device0, + location=location4, + floorplan=floor0, + organization=org, + ) + self._create_object_location( + content_object=device22, + location=location4, + floorplan=floor22, + organization=org, + ) + path = reverse("geo_api:indoor_coordinates_list", args=[location4.id]) + response = self.client.get(path) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["results"]), 1) + self.assertEqual(response.data["results"][0]["device_name"], "device-0") + self.assertEqual(response.data["results"][0]["floor"], 0) diff --git a/openwisp_controller/geo/utils.py b/openwisp_controller/geo/utils.py index 1b8e0783d..c113b116b 100644 --- a/openwisp_controller/geo/utils.py +++ b/openwisp_controller/geo/utils.py @@ -41,4 +41,9 @@ def get_geo_urls(geo_views): geo_views.detail_location, name="detail_location", ), + path( + "api/v1/controller/location//indoor-coordinates/", + geo_views.indoor_coordinates_list, + name="indoor_coordinates_list", + ), ] diff --git a/tests/openwisp2/sample_geo/views.py b/tests/openwisp2/sample_geo/views.py index f0fc52bb0..6aae10d31 100644 --- a/tests/openwisp2/sample_geo/views.py +++ b/tests/openwisp2/sample_geo/views.py @@ -13,6 +13,9 @@ from openwisp_controller.geo.api.views import ( GeoJsonLocationList as BaseGeoJsonLocationList, ) +from openwisp_controller.geo.api.views import ( + IndoorCoordinatesList as BaseIndoorCoordinatesList, +) from openwisp_controller.geo.api.views import ( LocationDetailView as BaseLocationDetailView, ) @@ -56,11 +59,16 @@ class LocationDetailView(BaseLocationDetailView): pass +class IndoorCoordinatesList(BaseIndoorCoordinatesList): + pass + + device_coordinates = DeviceCoordinatesView.as_view() device_location = DeviceLocationView.as_view() geojson = GeoJsonLocationList.as_view() location_device_list = LocationDeviceList.as_view() list_floorplan = FloorPlanListCreateView.as_view() +indoor_coordinates_list = IndoorCoordinatesList.as_view() detail_floorplan = FloorPlanDetailView.as_view() list_location = LocationListCreateView.as_view() detail_location = LocationDetailView.as_view()