From 99392cb4a0de6b9167d417306ff9a324a43ca759 Mon Sep 17 00:00:00 2001 From: dee077 Date: Fri, 7 Nov 2025 17:42:18 +0530 Subject: [PATCH 1/6] [feature] Add websocket endpoint for bulk location updates #191 Implemented a new websocket endpoint 'location/all/' to handle updates for all locations simultaneously. Fixes #191 --- django_loci/channels/asgi.py | 9 +++++++-- django_loci/channels/base.py | 21 +++++++++++++++++++++ django_loci/channels/consumers.py | 5 ++++- django_loci/channels/receivers.py | 19 +++++++++++++++++++ django_loci/static/django-loci/js/loci.js | 13 +++++++++++++ 5 files changed, 64 insertions(+), 3 deletions(-) diff --git a/django_loci/channels/asgi.py b/django_loci/channels/asgi.py index 1b691317..63945fe9 100644 --- a/django_loci/channels/asgi.py +++ b/django_loci/channels/asgi.py @@ -4,8 +4,8 @@ from django.core.asgi import get_asgi_application from django.urls import path -from django_loci.channels.base import location_broadcast_path -from django_loci.channels.consumers import LocationBroadcast +from django_loci.channels.base import location_broadcast_path, all_location_boradcast_path +from django_loci.channels.consumers import LocationBroadcast, AllLocationBroadcast channel_routing = ProtocolTypeRouter( { @@ -17,6 +17,11 @@ location_broadcast_path, LocationBroadcast.as_asgi(), name="LocationChannel", + ), + path( + all_location_boradcast_path, + AllLocationBroadcast.as_asgi(), + name="AllLocationChannel", ) ] ) diff --git a/django_loci/channels/base.py b/django_loci/channels/base.py index 0ad608a0..19be5b4f 100644 --- a/django_loci/channels/base.py +++ b/django_loci/channels/base.py @@ -3,6 +3,7 @@ from django.core.exceptions import ValidationError location_broadcast_path = "ws/loci/location//" +all_location_boradcast_path = "ws/loci/location/all/" def _get_object_or_none(model, **kwargs): @@ -61,3 +62,23 @@ def disconnect(self, close_code): async_to_sync(self.channel_layer.group_discard)( self.group_name, self.channel_name ) + +class BaseAllLocationBroadcast(BaseLocationBroadcast): + + def connect(self): + """ + Modified connect to handle all locations subscription without location pk + """ + try: + user = self.scope["user"] + except KeyError: + self.close() + else: + if not self.is_authorized(user, None): + self.close() + return + self.accept() + self.group_name = "loci.mobile-location.all" + async_to_sync(self.channel_layer.group_add)( + self.group_name, self.channel_name + ) diff --git a/django_loci/channels/consumers.py b/django_loci/channels/consumers.py index 7230113d..89507311 100644 --- a/django_loci/channels/consumers.py +++ b/django_loci/channels/consumers.py @@ -1,6 +1,9 @@ from ..models import Location -from .base import BaseLocationBroadcast +from .base import BaseLocationBroadcast, BaseAllLocationBroadcast class LocationBroadcast(BaseLocationBroadcast): model = Location + +class AllLocationBroadcast(BaseAllLocationBroadcast): + model = Location diff --git a/django_loci/channels/receivers.py b/django_loci/channels/receivers.py index 31f16898..09db4995 100644 --- a/django_loci/channels/receivers.py +++ b/django_loci/channels/receivers.py @@ -18,6 +18,22 @@ def update_mobile_location(sender, instance, **kwargs): group_name, {"type": "send_message", "message": message} ) +def update_mobile_all_locations(sender, instance, **kwargs): + if not kwargs.get("created") and instance.geometry: + channel_layer = channels.layers.get_channel_layer() + group_name = "loci.mobile-location.all" + message = { + "id": str(instance.pk), + "geometry": json.loads(instance.geometry.geojson), + "address": instance.address, + "name": instance.name, + "type": instance.type, + "is_mobile": instance.is_mobile + } + async_to_sync(channel_layer.group_send)( + group_name, {"type": "send_message", "message": message} + ) + def load_location_receivers(sender): """ @@ -29,3 +45,6 @@ def load_location_receivers(sender): receiver(post_save, sender=sender, dispatch_uid="ws_update_mobile_location")( update_mobile_location ) + receiver(post_save, sender=sender, dispatch_uid="ws_update_mobile_location_all")( + update_mobile_all_locations + ) diff --git a/django_loci/static/django-loci/js/loci.js b/django_loci/static/django-loci/js/loci.js index d8d591d6..aadefcdf 100644 --- a/django_loci/static/django-loci/js/loci.js +++ b/django_loci/static/django-loci/js/loci.js @@ -63,6 +63,19 @@ django.jQuery(function ($) { return text; }; } + // Todo: Remove this before merging only added for testing + function listenForAllLocationUpdates() { + var host = window.location.host, + protocol = window.location.protocol === "http:" ? "ws" : "wss", + ws = new ReconnectingWebSocket( + protocol + "://" + host + "/ws/loci/location/all/", + ); + ws.onmessage = function (e) { + const data = JSON.parse(e.data); + console.log("From /ws/loci/location/all/",data) + }; + } + listenForAllLocationUpdates() function getLocationJsonUrl(pk) { return baseLocationJsonUrl.replace("00000000-0000-0000-0000-000000000000", pk); From 71176daff3e64b7373463fdcb0a4fb5c9b612c5b Mon Sep 17 00:00:00 2001 From: dee077 Date: Fri, 7 Nov 2025 17:53:21 +0530 Subject: [PATCH 2/6] [fix] Qa --- django_loci/channels/asgi.py | 9 ++++++--- django_loci/channels/base.py | 1 + django_loci/channels/consumers.py | 3 ++- django_loci/channels/receivers.py | 5 +++-- django_loci/static/django-loci/js/loci.js | 4 ++-- 5 files changed, 14 insertions(+), 8 deletions(-) diff --git a/django_loci/channels/asgi.py b/django_loci/channels/asgi.py index 63945fe9..8f2772fe 100644 --- a/django_loci/channels/asgi.py +++ b/django_loci/channels/asgi.py @@ -4,8 +4,11 @@ from django.core.asgi import get_asgi_application from django.urls import path -from django_loci.channels.base import location_broadcast_path, all_location_boradcast_path -from django_loci.channels.consumers import LocationBroadcast, AllLocationBroadcast +from django_loci.channels.base import ( + all_location_boradcast_path, + location_broadcast_path, +) +from django_loci.channels.consumers import AllLocationBroadcast, LocationBroadcast channel_routing = ProtocolTypeRouter( { @@ -22,7 +25,7 @@ all_location_boradcast_path, AllLocationBroadcast.as_asgi(), name="AllLocationChannel", - ) + ), ] ) ) diff --git a/django_loci/channels/base.py b/django_loci/channels/base.py index 19be5b4f..20bb35a6 100644 --- a/django_loci/channels/base.py +++ b/django_loci/channels/base.py @@ -63,6 +63,7 @@ def disconnect(self, close_code): self.group_name, self.channel_name ) + class BaseAllLocationBroadcast(BaseLocationBroadcast): def connect(self): diff --git a/django_loci/channels/consumers.py b/django_loci/channels/consumers.py index 89507311..f4b71c47 100644 --- a/django_loci/channels/consumers.py +++ b/django_loci/channels/consumers.py @@ -1,9 +1,10 @@ from ..models import Location -from .base import BaseLocationBroadcast, BaseAllLocationBroadcast +from .base import BaseAllLocationBroadcast, BaseLocationBroadcast class LocationBroadcast(BaseLocationBroadcast): model = Location + class AllLocationBroadcast(BaseAllLocationBroadcast): model = Location diff --git a/django_loci/channels/receivers.py b/django_loci/channels/receivers.py index 09db4995..18b04af9 100644 --- a/django_loci/channels/receivers.py +++ b/django_loci/channels/receivers.py @@ -18,6 +18,7 @@ def update_mobile_location(sender, instance, **kwargs): group_name, {"type": "send_message", "message": message} ) + def update_mobile_all_locations(sender, instance, **kwargs): if not kwargs.get("created") and instance.geometry: channel_layer = channels.layers.get_channel_layer() @@ -28,7 +29,7 @@ def update_mobile_all_locations(sender, instance, **kwargs): "address": instance.address, "name": instance.name, "type": instance.type, - "is_mobile": instance.is_mobile + "is_mobile": instance.is_mobile, } async_to_sync(channel_layer.group_send)( group_name, {"type": "send_message", "message": message} @@ -46,5 +47,5 @@ def load_location_receivers(sender): update_mobile_location ) receiver(post_save, sender=sender, dispatch_uid="ws_update_mobile_location_all")( - update_mobile_all_locations + update_mobile_all_locations ) diff --git a/django_loci/static/django-loci/js/loci.js b/django_loci/static/django-loci/js/loci.js index aadefcdf..ee601a31 100644 --- a/django_loci/static/django-loci/js/loci.js +++ b/django_loci/static/django-loci/js/loci.js @@ -72,10 +72,10 @@ django.jQuery(function ($) { ); ws.onmessage = function (e) { const data = JSON.parse(e.data); - console.log("From /ws/loci/location/all/",data) + console.log("From /ws/loci/location/all/", data); }; } - listenForAllLocationUpdates() + listenForAllLocationUpdates(); function getLocationJsonUrl(pk) { return baseLocationJsonUrl.replace("00000000-0000-0000-0000-000000000000", pk); From cdb67fe615fb047fa2d5ab451c6b1e34f0e8e6a3 Mon Sep 17 00:00:00 2001 From: dee077 Date: Wed, 3 Dec 2025 02:21:29 +0530 Subject: [PATCH 3/6] [fix] Add tests and docs --- README.rst | 11 +++ django_loci/channels/asgi.py | 4 +- django_loci/channels/base.py | 2 +- django_loci/tests/base/test_channels.py | 98 ++++++++++++++++++++++++- 4 files changed, 111 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index 82fc1f9a..8f3f0ff7 100644 --- a/README.rst +++ b/README.rst @@ -482,6 +482,17 @@ Extend the channel consumer of django-loci in this way: class LocationBroadcast(BaseLocationBroadcast): model = Location +Extend the broadcast consumer for all locations: + +.. code-block:: python + + from django_loci.channels.base import BaseAllLocationBroadcast + from ..models import Location # your own location model + + + class AllLocationBroadcast(BaseAllLocationBroadcast): + model = Location + Extending AppConfig ~~~~~~~~~~~~~~~~~~~ diff --git a/django_loci/channels/asgi.py b/django_loci/channels/asgi.py index 8f2772fe..6785fed5 100644 --- a/django_loci/channels/asgi.py +++ b/django_loci/channels/asgi.py @@ -5,7 +5,7 @@ from django.urls import path from django_loci.channels.base import ( - all_location_boradcast_path, + all_location_broadcast_path, location_broadcast_path, ) from django_loci.channels.consumers import AllLocationBroadcast, LocationBroadcast @@ -22,7 +22,7 @@ name="LocationChannel", ), path( - all_location_boradcast_path, + all_location_broadcast_path, AllLocationBroadcast.as_asgi(), name="AllLocationChannel", ), diff --git a/django_loci/channels/base.py b/django_loci/channels/base.py index 20bb35a6..da568f87 100644 --- a/django_loci/channels/base.py +++ b/django_loci/channels/base.py @@ -3,7 +3,7 @@ from django.core.exceptions import ValidationError location_broadcast_path = "ws/loci/location//" -all_location_boradcast_path = "ws/loci/location/all/" +all_location_broadcast_path = "ws/loci/location/all/" def _get_object_or_none(model, **kwargs): diff --git a/django_loci/tests/base/test_channels.py b/django_loci/tests/base/test_channels.py index cd9ade4d..d43624f2 100644 --- a/django_loci/tests/base/test_channels.py +++ b/django_loci/tests/base/test_channels.py @@ -10,7 +10,7 @@ from django.contrib.auth.models import Permission from django.http.request import HttpRequest -from django_loci.channels.consumers import LocationBroadcast +from django_loci.channels.consumers import AllLocationBroadcast, LocationBroadcast from ...channels.base import _get_object_or_none from .. import TestAdminMixin, TestLociMixin @@ -45,6 +45,16 @@ async def _get_request_dict(self, pk=None, user=None): session = await self._force_login(user) return {"pk": pk, "path": path, "session": session} + async def _get_request_dict_for_all_location(self, user=None): + location = await database_sync_to_async(self._create_location)(is_mobile=True) + await database_sync_to_async(self._create_object_location)(location=location) + pk = location.pk + path = "/ws/loci/location/all/" + session = None + if user: + session = await self._force_login(user) + return {"pk": pk, "path": path, "session": session} + def _get_communicator(self, request_vars, user=None): communicator = WebsocketCommunicator( LocationBroadcast.as_asgi(), request_vars["path"] @@ -59,6 +69,19 @@ def _get_communicator(self, request_vars, user=None): ) return communicator + def _get_communicator_for_all_location(self, request_vars, user=None): + communicator = WebsocketCommunicator( + AllLocationBroadcast.as_asgi(), request_vars["path"] + ) + if user: + communicator.scope.update( + { + "user": user, + "session": request_vars["session"], + } + ) + return communicator + @pytest.mark.django_db(transaction=True) def test_object_or_none(self): result = _get_object_or_none(self.location_model, pk=1) @@ -76,6 +99,15 @@ async def test_consumer_unauthenticated(self): assert not connected await communicator.disconnect() + @pytest.mark.asyncio + @pytest.mark.django_db(transaction=True) + async def test_consumer_unauthenticated_for_all_location(self): + request_vars = await self._get_request_dict_for_all_location() + communicator = self._get_communicator_for_all_location(request_vars) + connected, _ = await communicator.connect() + assert not connected + await communicator.disconnect() + @pytest.mark.asyncio @pytest.mark.django_db(transaction=True) async def test_connect_admin(self): @@ -86,6 +118,16 @@ async def test_connect_admin(self): assert connected await communicator.disconnect() + @pytest.mark.asyncio + @pytest.mark.django_db(transaction=True) + async def test_connect_admin_for_all_location(self): + test_user = await database_sync_to_async(self._create_admin)() + request_vars = await self._get_request_dict_for_all_location(user=test_user) + communicator = self._get_communicator_for_all_location(request_vars, test_user) + connected, _ = await communicator.connect() + assert connected + await communicator.disconnect() + @pytest.mark.asyncio @pytest.mark.django_db(transaction=True) async def test_consumer_not_staff(self): @@ -98,6 +140,18 @@ async def test_consumer_not_staff(self): assert not connected await communicator.disconnect() + @pytest.mark.asyncio + @pytest.mark.django_db(transaction=True) + async def test_consumer_not_staff_for_all_location(self): + test_user = await database_sync_to_async(self.user_model.objects.create_user)( + username="user", password="password", email="test@test.org" + ) + request_vars = await self._get_request_dict_for_all_location(user=test_user) + communicator = self._get_communicator_for_all_location(request_vars, test_user) + connected, _ = await communicator.connect() + assert not connected + await communicator.disconnect() + @pytest.mark.asyncio @pytest.mark.django_db(transaction=True) async def test_consumer_404(self): @@ -133,6 +187,28 @@ async def test_consumer_staff_but_no_change_permission(self): assert connected await communicator.disconnect() + @pytest.mark.asyncio + @pytest.mark.django_db(transaction=True) + async def test_consumer_staff_but_no_change_permission_for_all_location(self): + test_user = await database_sync_to_async(self.user_model.objects.create_user)( + username="user", password="password", email="test@test.org", is_staff=True + ) + location = await database_sync_to_async(self._create_location)(is_mobile=True) + await database_sync_to_async(self._create_object_location)(location=location) + request_vars = await self._get_request_dict_for_all_location(user=test_user) + communicator = self._get_communicator_for_all_location(request_vars, test_user) + connected, _ = await communicator.connect() + assert not connected + await communicator.disconnect() + # add permission to change location and repeat + loc_perm = await Permission.objects.filter(name="Can change location").afirst() + await test_user.user_permissions.aadd(loc_perm) + test_user = await self.user_model.objects.aget(pk=test_user.pk) + communicator = self._get_communicator_for_all_location(request_vars, test_user) + connected, _ = await communicator.connect() + assert connected + await communicator.disconnect() + @pytest.mark.asyncio @pytest.mark.django_db(transaction=True) async def test_location_update(self): @@ -149,6 +225,26 @@ async def test_location_update(self): } await communicator.disconnect() + @pytest.mark.asyncio + @pytest.mark.django_db(transaction=True) + async def test_location_update_for_all_location(self): + test_user = await database_sync_to_async(self._create_admin)() + request_vars = await self._get_request_dict_for_all_location(user=test_user) + communicator = self._get_communicator_for_all_location(request_vars, test_user) + connected, _ = await communicator.connect() + assert connected + await self._save_location(request_vars["pk"]) + response = await communicator.receive_json_from() + assert response == { + "id": str(request_vars["pk"]), + "geometry": {"type": "Point", "coordinates": [12.513124, 41.897903]}, + "address": "Via del Corso, Roma, Italia", + "name": "test-location", + "type": "outdoor", + "is_mobile": True, + } + await communicator.disconnect() + async def _save_location(self, pk): loc = await self.location_model.objects.aget(pk=pk) loc.geometry = "POINT (12.513124 41.897903)" From d58772f154a2431429b2e7bb184bef2065097bb5 Mon Sep 17 00:00:00 2001 From: dee077 Date: Wed, 10 Dec 2025 17:43:37 +0530 Subject: [PATCH 4/6] [fix] Change naming conventions for paths and tests --- README.rst | 4 +- django_loci/channels/asgi.py | 8 +- django_loci/channels/base.py | 8 +- django_loci/channels/consumers.py | 4 +- django_loci/channels/receivers.py | 28 ++--- django_loci/static/django-loci/js/loci.js | 15 +-- django_loci/tests/base/test_channels.py | 118 ++++++++++++---------- 7 files changed, 89 insertions(+), 96 deletions(-) diff --git a/README.rst b/README.rst index 8f3f0ff7..77cae1dc 100644 --- a/README.rst +++ b/README.rst @@ -486,11 +486,11 @@ Extend the broadcast consumer for all locations: .. code-block:: python - from django_loci.channels.base import BaseAllLocationBroadcast + from django_loci.channels.base import BaseCommonLocationBroadcast from ..models import Location # your own location model - class AllLocationBroadcast(BaseAllLocationBroadcast): + class CommonLocationBroadcast(BaseCommonLocationBroadcast): model = Location Extending AppConfig diff --git a/django_loci/channels/asgi.py b/django_loci/channels/asgi.py index 6785fed5..b474b639 100644 --- a/django_loci/channels/asgi.py +++ b/django_loci/channels/asgi.py @@ -5,10 +5,10 @@ from django.urls import path from django_loci.channels.base import ( - all_location_broadcast_path, + common_location_broadcast_path, location_broadcast_path, ) -from django_loci.channels.consumers import AllLocationBroadcast, LocationBroadcast +from django_loci.channels.consumers import CommonLocationBroadcast, LocationBroadcast channel_routing = ProtocolTypeRouter( { @@ -22,8 +22,8 @@ name="LocationChannel", ), path( - all_location_broadcast_path, - AllLocationBroadcast.as_asgi(), + common_location_broadcast_path, + CommonLocationBroadcast.as_asgi(), name="AllLocationChannel", ), ] diff --git a/django_loci/channels/base.py b/django_loci/channels/base.py index da568f87..3532f9be 100644 --- a/django_loci/channels/base.py +++ b/django_loci/channels/base.py @@ -2,8 +2,8 @@ from channels.generic.websocket import JsonWebsocketConsumer from django.core.exceptions import ValidationError -location_broadcast_path = "ws/loci/location//" -all_location_broadcast_path = "ws/loci/location/all/" +location_broadcast_path = "ws/loci/locations//" +common_location_broadcast_path = "ws/loci/locations/" def _get_object_or_none(model, **kwargs): @@ -64,7 +64,7 @@ def disconnect(self, close_code): ) -class BaseAllLocationBroadcast(BaseLocationBroadcast): +class BaseCommonLocationBroadcast(BaseLocationBroadcast): def connect(self): """ @@ -79,7 +79,7 @@ def connect(self): self.close() return self.accept() - self.group_name = "loci.mobile-location.all" + self.group_name = "loci.mobile-location.common" async_to_sync(self.channel_layer.group_add)( self.group_name, self.channel_name ) diff --git a/django_loci/channels/consumers.py b/django_loci/channels/consumers.py index f4b71c47..efc89304 100644 --- a/django_loci/channels/consumers.py +++ b/django_loci/channels/consumers.py @@ -1,10 +1,10 @@ from ..models import Location -from .base import BaseAllLocationBroadcast, BaseLocationBroadcast +from .base import BaseCommonLocationBroadcast, BaseLocationBroadcast class LocationBroadcast(BaseLocationBroadcast): model = Location -class AllLocationBroadcast(BaseAllLocationBroadcast): +class CommonLocationBroadcast(BaseCommonLocationBroadcast): model = Location diff --git a/django_loci/channels/receivers.py b/django_loci/channels/receivers.py index 18b04af9..3e458b19 100644 --- a/django_loci/channels/receivers.py +++ b/django_loci/channels/receivers.py @@ -7,23 +7,25 @@ def update_mobile_location(sender, instance, **kwargs): + """ + Sends WebSocket updates when a location record is updated. + - Sends a message with the specific location update. + - Sends a message with the common (all locations) update. + """ if not kwargs.get("created") and instance.geometry: - group_name = "loci.mobile-location.{0}".format(str(instance.pk)) channel_layer = channels.layers.get_channel_layer() - message = { + specific_location_group_name = f"loci.mobile-location.{instance.pk}" + specific_location_message = { "geometry": json.loads(instance.geometry.geojson), "address": instance.address, } async_to_sync(channel_layer.group_send)( - group_name, {"type": "send_message", "message": message} + specific_location_group_name, + {"type": "send_message", "message": specific_location_message}, ) - - -def update_mobile_all_locations(sender, instance, **kwargs): - if not kwargs.get("created") and instance.geometry: - channel_layer = channels.layers.get_channel_layer() - group_name = "loci.mobile-location.all" - message = { + # Broadcast update to track updates across all locations + common_location_group_name = "loci.mobile-location.common" + common_location_message = { "id": str(instance.pk), "geometry": json.loads(instance.geometry.geojson), "address": instance.address, @@ -32,7 +34,8 @@ def update_mobile_all_locations(sender, instance, **kwargs): "is_mobile": instance.is_mobile, } async_to_sync(channel_layer.group_send)( - group_name, {"type": "send_message", "message": message} + common_location_group_name, + {"type": "send_message", "message": common_location_message}, ) @@ -46,6 +49,3 @@ def load_location_receivers(sender): receiver(post_save, sender=sender, dispatch_uid="ws_update_mobile_location")( update_mobile_location ) - receiver(post_save, sender=sender, dispatch_uid="ws_update_mobile_location_all")( - update_mobile_all_locations - ) diff --git a/django_loci/static/django-loci/js/loci.js b/django_loci/static/django-loci/js/loci.js index ee601a31..4d2636b3 100644 --- a/django_loci/static/django-loci/js/loci.js +++ b/django_loci/static/django-loci/js/loci.js @@ -63,19 +63,6 @@ django.jQuery(function ($) { return text; }; } - // Todo: Remove this before merging only added for testing - function listenForAllLocationUpdates() { - var host = window.location.host, - protocol = window.location.protocol === "http:" ? "ws" : "wss", - ws = new ReconnectingWebSocket( - protocol + "://" + host + "/ws/loci/location/all/", - ); - ws.onmessage = function (e) { - const data = JSON.parse(e.data); - console.log("From /ws/loci/location/all/", data); - }; - } - listenForAllLocationUpdates(); function getLocationJsonUrl(pk) { return baseLocationJsonUrl.replace("00000000-0000-0000-0000-000000000000", pk); @@ -447,7 +434,7 @@ django.jQuery(function ($) { var host = window.location.host, protocol = window.location.protocol === "http:" ? "ws" : "wss", ws = new ReconnectingWebSocket( - protocol + "://" + host + "/ws/loci/location/" + pk + "/", + protocol + "://" + host + "/ws/loci/locations/" + pk + "/", ); ws.onmessage = function (e) { const data = JSON.parse(e.data); diff --git a/django_loci/tests/base/test_channels.py b/django_loci/tests/base/test_channels.py index d43624f2..cdd85d4e 100644 --- a/django_loci/tests/base/test_channels.py +++ b/django_loci/tests/base/test_channels.py @@ -10,7 +10,7 @@ from django.contrib.auth.models import Permission from django.http.request import HttpRequest -from django_loci.channels.consumers import AllLocationBroadcast, LocationBroadcast +from django_loci.channels.consumers import CommonLocationBroadcast, LocationBroadcast from ...channels.base import _get_object_or_none from .. import TestAdminMixin, TestLociMixin @@ -30,7 +30,7 @@ async def _force_login(self, user, backend=None): request.session.save return request.session - async def _get_request_dict(self, pk=None, user=None): + async def _get_specific_location_request_dict(self, pk=None, user=None): if not pk: location = await database_sync_to_async(self._create_location)( is_mobile=True @@ -39,48 +39,47 @@ async def _get_request_dict(self, pk=None, user=None): location=location ) pk = location.pk - path = "/ws/loci/location/{0}/".format(pk) + path = "/ws/loci/locations/{0}/".format(pk) session = None if user: session = await self._force_login(user) return {"pk": pk, "path": path, "session": session} - async def _get_request_dict_for_all_location(self, user=None): + async def _get_common_location_request_dict(self, user=None): location = await database_sync_to_async(self._create_location)(is_mobile=True) await database_sync_to_async(self._create_object_location)(location=location) pk = location.pk - path = "/ws/loci/location/all/" + path = "/ws/loci/locations/" session = None if user: session = await self._force_login(user) return {"pk": pk, "path": path, "session": session} - def _get_communicator(self, request_vars, user=None): + def _get_location_communicator( + self, consumer_cls, request_vars, user=None, include_pk=False + ): communicator = WebsocketCommunicator( - LocationBroadcast.as_asgi(), request_vars["path"] + consumer_cls.as_asgi(), request_vars["path"] ) if user: - communicator.scope.update( - { - "user": user, - "session": request_vars["session"], - "url_route": {"kwargs": {"pk": request_vars["pk"]}}, - } - ) + scope = { + "user": user, + "session": request_vars["session"], + } + if include_pk: + scope["url_route"] = {"kwargs": {"pk": request_vars["pk"]}} + communicator.scope.update(scope) return communicator - def _get_communicator_for_all_location(self, request_vars, user=None): - communicator = WebsocketCommunicator( - AllLocationBroadcast.as_asgi(), request_vars["path"] + def _get_specific_location_communicator(self, request_vars, user=None): + return self._get_location_communicator( + LocationBroadcast, request_vars, user=user, include_pk=True + ) + + def _get_common_location_communicator(self, request_vars, user=None): + return self._get_location_communicator( + CommonLocationBroadcast, request_vars, user=user, include_pk=False ) - if user: - communicator.scope.update( - { - "user": user, - "session": request_vars["session"], - } - ) - return communicator @pytest.mark.django_db(transaction=True) def test_object_or_none(self): @@ -93,17 +92,17 @@ def test_object_or_none(self): @pytest.mark.asyncio @pytest.mark.django_db(transaction=True) async def test_consumer_unauthenticated(self): - request_vars = await self._get_request_dict() - communicator = self._get_communicator(request_vars) + request_vars = await self._get_specific_location_request_dict() + communicator = self._get_specific_location_communicator(request_vars) connected, _ = await communicator.connect() assert not connected await communicator.disconnect() @pytest.mark.asyncio @pytest.mark.django_db(transaction=True) - async def test_consumer_unauthenticated_for_all_location(self): - request_vars = await self._get_request_dict_for_all_location() - communicator = self._get_communicator_for_all_location(request_vars) + async def test_common_location_consumer_unauthenticated(self): + request_vars = await self._get_common_location_request_dict() + communicator = self._get_common_location_communicator(request_vars) connected, _ = await communicator.connect() assert not connected await communicator.disconnect() @@ -112,18 +111,18 @@ async def test_consumer_unauthenticated_for_all_location(self): @pytest.mark.django_db(transaction=True) async def test_connect_admin(self): test_user = await database_sync_to_async(self._create_admin)() - request_vars = await self._get_request_dict(user=test_user) - communicator = self._get_communicator(request_vars, test_user) + request_vars = await self._get_specific_location_request_dict(user=test_user) + communicator = self._get_specific_location_communicator(request_vars, test_user) connected, _ = await communicator.connect() assert connected await communicator.disconnect() @pytest.mark.asyncio @pytest.mark.django_db(transaction=True) - async def test_connect_admin_for_all_location(self): + async def test_common_location_connect_admin(self): test_user = await database_sync_to_async(self._create_admin)() - request_vars = await self._get_request_dict_for_all_location(user=test_user) - communicator = self._get_communicator_for_all_location(request_vars, test_user) + request_vars = await self._get_common_location_request_dict(user=test_user) + communicator = self._get_common_location_communicator(request_vars, test_user) connected, _ = await communicator.connect() assert connected await communicator.disconnect() @@ -134,20 +133,20 @@ async def test_consumer_not_staff(self): test_user = await database_sync_to_async(self.user_model.objects.create_user)( username="user", password="password", email="test@test.org" ) - request_vars = await self._get_request_dict(user=test_user) - communicator = self._get_communicator(request_vars, test_user) + request_vars = await self._get_specific_location_request_dict(user=test_user) + communicator = self._get_specific_location_communicator(request_vars, test_user) connected, _ = await communicator.connect() assert not connected await communicator.disconnect() @pytest.mark.asyncio @pytest.mark.django_db(transaction=True) - async def test_consumer_not_staff_for_all_location(self): + async def test_common_location_consumer_not_staff(self): test_user = await database_sync_to_async(self.user_model.objects.create_user)( username="user", password="password", email="test@test.org" ) - request_vars = await self._get_request_dict_for_all_location(user=test_user) - communicator = self._get_communicator_for_all_location(request_vars, test_user) + request_vars = await self._get_common_location_request_dict(user=test_user) + communicator = self._get_common_location_communicator(request_vars, test_user) connected, _ = await communicator.connect() assert not connected await communicator.disconnect() @@ -157,8 +156,8 @@ async def test_consumer_not_staff_for_all_location(self): async def test_consumer_404(self): pk = self.location_model().pk admin = await database_sync_to_async(self._create_admin)() - request_vars = await self._get_request_dict(pk=pk, user=admin) - communicator = self._get_communicator(request_vars, admin) + request_vars = await self._get_specific_location_request_dict(pk=pk, user=admin) + communicator = self._get_specific_location_communicator(request_vars, admin) connected, _ = await communicator.connect() assert not connected @@ -173,38 +172,45 @@ async def test_consumer_staff_but_no_change_permission(self): location=location ) pk = ol.location.pk - request_vars = await self._get_request_dict(pk=pk, user=test_user) - communicator = self._get_communicator(request_vars, test_user) + request_vars = await self._get_specific_location_request_dict( + pk=pk, user=test_user + ) + communicator = self._get_specific_location_communicator(request_vars, test_user) connected, _ = await communicator.connect() assert not connected await communicator.disconnect() + # add permission to change location and repeat - loc_perm = await Permission.objects.filter(name="Can change location").afirst() + loc_perm = await Permission.objects.filter( + codename=f"change_{self.location_model._meta.model_name}" + ).afirst() await test_user.user_permissions.aadd(loc_perm) test_user = await self.user_model.objects.aget(pk=test_user.pk) - communicator = self._get_communicator(request_vars, test_user) + communicator = self._get_specific_location_communicator(request_vars, test_user) connected, _ = await communicator.connect() assert connected await communicator.disconnect() @pytest.mark.asyncio @pytest.mark.django_db(transaction=True) - async def test_consumer_staff_but_no_change_permission_for_all_location(self): + async def test_common_location_consumer_staff_but_no_change_permission(self): test_user = await database_sync_to_async(self.user_model.objects.create_user)( username="user", password="password", email="test@test.org", is_staff=True ) location = await database_sync_to_async(self._create_location)(is_mobile=True) await database_sync_to_async(self._create_object_location)(location=location) - request_vars = await self._get_request_dict_for_all_location(user=test_user) - communicator = self._get_communicator_for_all_location(request_vars, test_user) + request_vars = await self._get_common_location_request_dict(user=test_user) + communicator = self._get_common_location_communicator(request_vars, test_user) connected, _ = await communicator.connect() assert not connected await communicator.disconnect() # add permission to change location and repeat - loc_perm = await Permission.objects.filter(name="Can change location").afirst() + loc_perm = await Permission.objects.filter( + codename=f"change_{self.location_model._meta.model_name}" + ).afirst() await test_user.user_permissions.aadd(loc_perm) test_user = await self.user_model.objects.aget(pk=test_user.pk) - communicator = self._get_communicator_for_all_location(request_vars, test_user) + communicator = self._get_common_location_communicator(request_vars, test_user) connected, _ = await communicator.connect() assert connected await communicator.disconnect() @@ -213,8 +219,8 @@ async def test_consumer_staff_but_no_change_permission_for_all_location(self): @pytest.mark.django_db(transaction=True) async def test_location_update(self): test_user = await database_sync_to_async(self._create_admin)() - request_vars = await self._get_request_dict(user=test_user) - communicator = self._get_communicator(request_vars, test_user) + request_vars = await self._get_specific_location_request_dict(user=test_user) + communicator = self._get_specific_location_communicator(request_vars, test_user) connected, _ = await communicator.connect() assert connected await self._save_location(request_vars["pk"]) @@ -227,10 +233,10 @@ async def test_location_update(self): @pytest.mark.asyncio @pytest.mark.django_db(transaction=True) - async def test_location_update_for_all_location(self): + async def test_common_location_update(self): test_user = await database_sync_to_async(self._create_admin)() - request_vars = await self._get_request_dict_for_all_location(user=test_user) - communicator = self._get_communicator_for_all_location(request_vars, test_user) + request_vars = await self._get_common_location_request_dict(user=test_user) + communicator = self._get_common_location_communicator(request_vars, test_user) connected, _ = await communicator.connect() assert connected await self._save_location(request_vars["pk"]) From 30449793e6d00c28681bca80df717bc3f4533a32 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Fri, 12 Dec 2025 22:10:23 +0530 Subject: [PATCH 5/6] [chores] Added a page for viewing location broadcasts --- django_loci/channels/receivers.py | 47 ++++++++++--------- django_loci/tests/testdeviceapp/admin.py | 20 ++++++++ .../admin/location_broadcast_listener.html | 29 ++++++++++++ 3 files changed, 75 insertions(+), 21 deletions(-) create mode 100644 django_loci/tests/testdeviceapp/templates/admin/location_broadcast_listener.html diff --git a/django_loci/channels/receivers.py b/django_loci/channels/receivers.py index 3e458b19..d1864d2e 100644 --- a/django_loci/channels/receivers.py +++ b/django_loci/channels/receivers.py @@ -9,33 +9,38 @@ def update_mobile_location(sender, instance, **kwargs): """ Sends WebSocket updates when a location record is updated. - - Sends a message with the specific location update. - - Sends a message with the common (all locations) update. + - Sends a message to the location specific group. + - Sends a message to a common group for tracking all mobile location updates. """ if not kwargs.get("created") and instance.geometry: channel_layer = channels.layers.get_channel_layer() - specific_location_group_name = f"loci.mobile-location.{instance.pk}" - specific_location_message = { - "geometry": json.loads(instance.geometry.geojson), - "address": instance.address, - } + + # Send update to location specific group async_to_sync(channel_layer.group_send)( - specific_location_group_name, - {"type": "send_message", "message": specific_location_message}, + f"loci.mobile-location.{instance.pk}", + { + "type": "send_message", + "message": { + "geometry": json.loads(instance.geometry.geojson), + "address": instance.address, + }, + }, ) - # Broadcast update to track updates across all locations - common_location_group_name = "loci.mobile-location.common" - common_location_message = { - "id": str(instance.pk), - "geometry": json.loads(instance.geometry.geojson), - "address": instance.address, - "name": instance.name, - "type": instance.type, - "is_mobile": instance.is_mobile, - } + + # Send update to common mobile location group async_to_sync(channel_layer.group_send)( - common_location_group_name, - {"type": "send_message", "message": common_location_message}, + "loci.mobile-location.common", + { + "type": "send_message", + "message": { + "id": str(instance.pk), + "geometry": json.loads(instance.geometry.geojson), + "address": instance.address, + "name": instance.name, + "type": instance.type, + "is_mobile": instance.is_mobile, + }, + }, ) diff --git a/django_loci/tests/testdeviceapp/admin.py b/django_loci/tests/testdeviceapp/admin.py index 2c8852f6..2f7c0e33 100644 --- a/django_loci/tests/testdeviceapp/admin.py +++ b/django_loci/tests/testdeviceapp/admin.py @@ -1,4 +1,6 @@ from django.contrib import admin +from django.shortcuts import render +from django.urls import path from django_loci.admin import ObjectLocationInline from openwisp_utils.admin import TimeReadonlyAdminMixin @@ -11,5 +13,23 @@ class DeviceAdmin(TimeReadonlyAdminMixin, admin.ModelAdmin): save_on_top = True inlines = [ObjectLocationInline] + def get_urls(self): + urls = super().get_urls() + urls = [ + path( + "location-broadcast-listener/", + self.admin_site.admin_view(self.location_broadcast_listener), + name="location-broadcast-listener", + ), + ] + urls + return urls + + def location_broadcast_listener(self, request): + return render( + request, + "admin/location_broadcast_listener.html", + {"title": "Location Broadcast Listener", "site_title": "OpenWISP 2"}, + ) + admin.site.register(Device, DeviceAdmin) diff --git a/django_loci/tests/testdeviceapp/templates/admin/location_broadcast_listener.html b/django_loci/tests/testdeviceapp/templates/admin/location_broadcast_listener.html new file mode 100644 index 00000000..273ba867 --- /dev/null +++ b/django_loci/tests/testdeviceapp/templates/admin/location_broadcast_listener.html @@ -0,0 +1,29 @@ +{% extends "admin/base_site.html" %} + +{% load static %} + +{% block content %} +
    +{% endblock content %} + +{% block footer %} +{{ block.super }} + + +{% endblock footer %} + + From 1a9d069bd415ed908face24ece4e58b58c2c4e4c Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Fri, 12 Dec 2025 23:11:21 +0530 Subject: [PATCH 6/6] [tests] Added test for location broadcast listener --- .github/workflows/ci.yml | 6 +++ README.rst | 6 +++ .../admin/location_broadcast_listener.html | 9 ++++ .../tests/testdeviceapp/tests/__init__.py | 0 .../testdeviceapp/tests/test_selenium.py | 44 +++++++++++++++++++ docker-compose.yml | 7 +++ tests/openwisp2/settings.py | 9 +++- 7 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 django_loci/tests/testdeviceapp/tests/__init__.py create mode 100644 django_loci/tests/testdeviceapp/tests/test_selenium.py create mode 100644 docker-compose.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d53359d1..ec1c1be9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,12 @@ jobs: name: Python==${{ matrix.python-version }} | ${{ matrix.django-version }} runs-on: ubuntu-24.04 + services: + redis: + image: redis + ports: + - 6379:6379 + strategy: fail-fast: false matrix: diff --git a/README.rst b/README.rst index 77cae1dc..f45dd564 100644 --- a/README.rst +++ b/README.rst @@ -535,6 +535,12 @@ Install test requirements: pip install -r requirements-test.txt +Launch Redis: + +.. code-block:: shell + + docker compose up -d redis + Create database: .. code-block:: shell diff --git a/django_loci/tests/testdeviceapp/templates/admin/location_broadcast_listener.html b/django_loci/tests/testdeviceapp/templates/admin/location_broadcast_listener.html index 273ba867..5ac1fbcd 100644 --- a/django_loci/tests/testdeviceapp/templates/admin/location_broadcast_listener.html +++ b/django_loci/tests/testdeviceapp/templates/admin/location_broadcast_listener.html @@ -1,8 +1,10 @@ {% extends "admin/base_site.html" %} {% load static %} +{% load i18n %} {% block content %} +
      {% endblock content %} @@ -23,6 +25,13 @@ li.appendChild(pre); locationUpdates.appendChild(li); }; + + ws.onopen = function() { + const statusMessage = document.querySelector('#ws-connected'); + if (statusMessage) { + statusMessage.classList.remove('hidden'); + } + }; {% endblock footer %} diff --git a/django_loci/tests/testdeviceapp/tests/__init__.py b/django_loci/tests/testdeviceapp/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django_loci/tests/testdeviceapp/tests/test_selenium.py b/django_loci/tests/testdeviceapp/tests/test_selenium.py new file mode 100644 index 00000000..4ef055af --- /dev/null +++ b/django_loci/tests/testdeviceapp/tests/test_selenium.py @@ -0,0 +1,44 @@ +from channels.testing import ChannelsLiveServerTestCase +from django.contrib.auth import get_user_model +from django.test import tag +from django.urls import reverse +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.support.ui import WebDriverWait + +from django_loci.models import Location, ObjectLocation +from django_loci.tests import TestAdminMixin, TestLociMixin +from openwisp_utils.tests.selenium import SeleniumTestMixin + + +@tag("selenium_tests") +class TestCommonLocationWebsocket( + SeleniumTestMixin, TestLociMixin, TestAdminMixin, ChannelsLiveServerTestCase +): + location_model = Location + object_location_model = ObjectLocation + user_model = get_user_model() + + def test_common_location_broadcast_ws(self): + self.login() + mobile_location = self._create_location(is_mobile=True) + self.open(reverse("admin:location-broadcast-listener")) + WebDriverWait(self.web_driver, 3).until( + EC.visibility_of_element_located( + (By.CSS_SELECTOR, "#ws-connected"), + ) + ) + # Update location to trigger websocket message + mobile_location.geometry = ( + '{ "type": "Point", "coordinates": [ 77.218791, 28.6324252 ] }' + ) + mobile_location.address = "Delhi, India" + mobile_location.full_clean() + mobile_location.save() + # Wait for websocket message to be received + WebDriverWait(self.web_driver, 3).until( + EC.text_to_be_present_in_element( + (By.CSS_SELECTOR, "#location-updates li"), + "77.218791", + ) + ) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..e65ab9d0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,7 @@ +--- +services: + redis: + image: redis:8-alpine + ports: + - "6379:6379" + entrypoint: redis-server --appendonly yes diff --git a/tests/openwisp2/settings.py b/tests/openwisp2/settings.py index e11d4d9e..280096fa 100644 --- a/tests/openwisp2/settings.py +++ b/tests/openwisp2/settings.py @@ -62,7 +62,14 @@ ROOT_URLCONF = "openwisp2.urls" ASGI_APPLICATION = "django_loci.channels.asgi.channel_routing" -CHANNEL_LAYERS = {"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}} +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": { + "hosts": [("127.0.0.1", 6379)], + }, + }, +} TIME_ZONE = "Europe/Rome" LANGUAGE_CODE = "en-gb"