Skip to content

Commit ef85ba3

Browse files
dee077nemesifier
authored andcommitted
[feature] Added API endpoint for indoor map coordinates #828
Implemented API to return indoor coordinates of devices for a given location. Closes #828 --------- Co-authored-by: Gagan Deep <pandafy.dev@gmail.com> [fix] Coderabbit comments
1 parent 2177fea commit ef85ba3

7 files changed

Lines changed: 469 additions & 19 deletions

File tree

docs/user/rest-api.rst

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -956,6 +956,29 @@ Delete Floor Plan
956956
957957
DELETE /api/v1/controller/floorplan/{pk}/
958958
959+
List Indoor Coordinates of a Location
960+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
961+
962+
.. note::
963+
964+
This endpoint returns device coordinates from the first floor above
965+
ground (lowest non-negative floors) by default. If a location only has
966+
negative floors (e.g. underground parking lot), then it will return
967+
the closest floor to the ground (greatest negative floor).
968+
969+
.. code-block:: text
970+
971+
GET /api/v1/controller/location/{id}/indoor-coordinates/
972+
973+
**Available filters**
974+
975+
You can filter using ``floor`` to get list of devices and their indoor
976+
coordinates for that floor.
977+
978+
.. code-block:: text
979+
980+
GET /api/v1/controller/location/{id}/indoor-coordinates/?floor={floor}
981+
959982
List Templates
960983
~~~~~~~~~~~~~~
961984

openwisp_controller/geo/api/serializers.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,3 +348,55 @@ def update(self, instance, validated_data):
348348
)
349349
validated_data = self._validate(validated_data)
350350
return super().update(instance, validated_data)
351+
352+
353+
class IndoorCoordinatesSerializer(serializers.ModelSerializer):
354+
admin_edit_url = SerializerMethodField("get_admin_edit_url")
355+
device_id = serializers.UUIDField(source="content_object.id", read_only=True)
356+
floorplan_id = serializers.UUIDField(source="floorplan.id", read_only=True)
357+
device_name = serializers.CharField(source="content_object.name")
358+
mac_address = serializers.CharField(source="content_object.mac_address")
359+
floor_name = serializers.SerializerMethodField()
360+
floor = serializers.IntegerField(source="floorplan.floor")
361+
image = serializers.ImageField(source="floorplan.image", read_only=True)
362+
coordinates = serializers.SerializerMethodField()
363+
364+
class Meta:
365+
model = DeviceLocation
366+
fields = [
367+
"id",
368+
"admin_edit_url",
369+
"device_id",
370+
"floorplan_id",
371+
"device_name",
372+
"mac_address",
373+
"floor_name",
374+
"floor",
375+
"image",
376+
"coordinates",
377+
]
378+
379+
def get_admin_edit_url(self, obj):
380+
return self.context["request"].build_absolute_uri(
381+
reverse(
382+
f"admin:{obj.content_object._meta.app_label}_device_change",
383+
args=(obj.content_object.id,),
384+
)
385+
)
386+
387+
def get_floor_name(self, obj):
388+
if not obj.floorplan:
389+
return None
390+
return str(obj.floorplan)
391+
392+
def get_coordinates(self, obj):
393+
"""
394+
NetJsonGraph expects indoor coordinates in {'lat': y, 'lng': x}.
395+
"""
396+
if not obj.indoor:
397+
return None
398+
try:
399+
y, x = (v.strip() for v in obj.indoor.split(",", 1))
400+
return {"lat": float(y), "lng": float(x)}
401+
except (ValueError, TypeError):
402+
return None

openwisp_controller/geo/api/views.py

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from django.core.exceptions import ObjectDoesNotExist, ValidationError
22
from django.db.models import Count
33
from django.http import Http404
4+
from django.utils.translation import gettext_lazy as _
45
from django_filters import rest_framework as filters
56
from rest_framework import generics, pagination, status
67
from rest_framework.exceptions import NotFound, PermissionDenied
@@ -14,13 +15,18 @@
1415
from openwisp_users.api.filters import OrganizationManagedFilter
1516
from openwisp_users.api.mixins import FilterByOrganizationManaged, FilterByParentManaged
1617

17-
from ...mixins import ProtectedAPIMixin, RelatedDeviceProtectedAPIMixin
18+
from ...mixins import (
19+
BaseProtectedAPIMixin,
20+
ProtectedAPIMixin,
21+
RelatedDeviceProtectedAPIMixin,
22+
)
1823
from .filters import DeviceListFilter
1924
from .serializers import (
2025
DeviceCoordinatesSerializer,
2126
DeviceLocationSerializer,
2227
FloorPlanSerializer,
2328
GeoJsonLocationSerializer,
29+
IndoorCoordinatesSerializer,
2430
LocationDeviceSerializer,
2531
LocationSerializer,
2632
)
@@ -51,6 +57,37 @@ class Meta(OrganizationManagedFilter.Meta):
5157
model = FloorPlan
5258

5359

60+
class IndoorCoordinatesFilter(filters.FilterSet):
61+
floor = filters.NumberFilter(label=_("Floor"), method="filter_by_floor")
62+
63+
@property
64+
def qs(self):
65+
qs = super().qs
66+
if "floor" not in self.data:
67+
qs = self.filter_by_floor(qs, "floor", None)
68+
return qs
69+
70+
def filter_by_floor(self, queryset, name, value):
71+
"""
72+
If no floor parameter is provided:
73+
- Return data for the first available non-negative floor.
74+
- If no non-negative floor exists, return data for the maximum negative floor.
75+
"""
76+
if value is not None:
77+
return queryset.filter(floorplan__floor=value)
78+
# No floor parameter provided
79+
floors = list(queryset.values_list("floorplan__floor", flat=True).distinct())
80+
if not floors:
81+
return queryset.none()
82+
non_negative_floors = [f for f in floors if f >= 0]
83+
default_floor = min(non_negative_floors) if non_negative_floors else max(floors)
84+
return queryset.filter(floorplan__floor=default_floor)
85+
86+
class Meta(OrganizationManagedFilter.Meta):
87+
model = DeviceLocation
88+
fields = ["floor"]
89+
90+
5491
class ListViewPagination(pagination.PageNumberPagination):
5592
page_size = 10
5693
page_size_query_param = "page_size"
@@ -187,6 +224,47 @@ class GeoJsonLocationList(
187224
filterset_class = LocationOrganizationFilter
188225

189226

227+
class IndoorCoordinatesViewPagination(ListViewPagination):
228+
page_size = 50
229+
230+
231+
class IndoorCoordinatesList(
232+
FilterByParentManaged, BaseProtectedAPIMixin, generics.ListAPIView
233+
):
234+
serializer_class = IndoorCoordinatesSerializer
235+
filter_backends = [filters.DjangoFilterBackend]
236+
filterset_class = IndoorCoordinatesFilter
237+
pagination_class = IndoorCoordinatesViewPagination
238+
queryset = (
239+
DeviceLocation.objects.filter(
240+
location__type="indoor",
241+
floorplan__isnull=False,
242+
)
243+
.select_related(
244+
"content_object", "location", "floorplan", "location__organization"
245+
)
246+
.order_by("floorplan__floor")
247+
)
248+
249+
def get_parent_queryset(self):
250+
qs = Location.objects.filter(pk=self.kwargs["pk"])
251+
return qs
252+
253+
def get_queryset(self):
254+
return super().get_queryset().filter(location_id=self.kwargs["pk"])
255+
256+
def get_available_floors(self, qs):
257+
floors = list(qs.values_list("floorplan__floor", flat=True).distinct())
258+
return floors
259+
260+
def list(self, request, *args, **kwargs):
261+
floors = self.get_available_floors(self.get_queryset())
262+
response = super().list(request, *args, **kwargs)
263+
if response.status_code == 200:
264+
response.data["floors"] = floors
265+
return response
266+
267+
190268
class LocationDeviceList(
191269
FilterByParentManaged, ProtectedAPIMixin, generics.ListAPIView
192270
):
@@ -203,6 +281,17 @@ def get_queryset(self):
203281
qs = Device.objects.filter(devicelocation__location_id=self.kwargs["pk"])
204282
return qs
205283

284+
def get_has_floorplan(self, qs):
285+
qs = qs.filter(devicelocation__floorplan__isnull=False).exists()
286+
return qs
287+
288+
def list(self, request, *args, **kwargs):
289+
has_floorplan = self.get_has_floorplan(self.get_queryset())
290+
response = super().list(request, *args, **kwargs)
291+
if response.status_code == 200:
292+
response.data["has_floorplan"] = has_floorplan
293+
return response
294+
206295

207296
class FloorPlanListCreateView(ProtectedAPIMixin, generics.ListCreateAPIView):
208297
serializer_class = FloorPlanSerializer
@@ -245,5 +334,6 @@ class LocationDetailView(
245334
location_device_list = LocationDeviceList.as_view()
246335
list_floorplan = FloorPlanListCreateView.as_view()
247336
detail_floorplan = FloorPlanDetailView.as_view()
337+
indoor_coordinates_list = IndoorCoordinatesList.as_view()
248338
list_location = LocationListCreateView.as_view()
249339
detail_location = LocationDetailView.as_view()

openwisp_controller/geo/apps.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import swapper
22
from django.conf import settings
3+
from django.db import transaction
34
from django.db.models import Case, Count, Sum, When
45
from django.utils.translation import gettext_lazy as _
56
from django_loci.apps import LociConfig
@@ -115,5 +116,41 @@ def register_menu_groups(self):
115116
},
116117
)
117118

119+
def _load_receivers(self):
120+
super()._load_receivers()
121+
post_save.connect(
122+
self._location_post_save_websocket_receiver,
123+
sender=self.location_model,
124+
dispatch_uid="geo_ws_update_mobile_location",
125+
)
126+
127+
def _location_post_save_websocket_receiver(
128+
self, sender, instance, created, **kwargs
129+
):
130+
"""
131+
Sends location updates over websockets to organization specific channel group.
132+
"""
133+
if created or not instance.geometry:
134+
return
135+
channel_layer = channels.layers.get_channel_layer()
136+
137+
def _send():
138+
async_to_sync(channel_layer.group_send)(
139+
f"loci.mobile-location.organization.{instance.organization_id}",
140+
{
141+
"type": "send_message",
142+
"message": {
143+
"id": str(instance.pk),
144+
"geometry": json.loads(instance.geometry.geojson),
145+
"address": instance.address,
146+
"name": instance.name,
147+
"type": instance.type,
148+
"is_mobile": instance.is_mobile,
149+
},
150+
},
151+
)
152+
153+
transaction.on_commit(_send)
154+
118155

119156
del LociConfig

0 commit comments

Comments
 (0)