diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d53359d..ec1c1be 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 82fc1f9..f45dd56 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 BaseCommonLocationBroadcast + from ..models import Location # your own location model + + + class CommonLocationBroadcast(BaseCommonLocationBroadcast): + model = Location + Extending AppConfig ~~~~~~~~~~~~~~~~~~~ @@ -524,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/channels/asgi.py b/django_loci/channels/asgi.py index 1b69131..b474b63 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 -from django_loci.channels.consumers import LocationBroadcast +from django_loci.channels.base import ( + common_location_broadcast_path, + location_broadcast_path, +) +from django_loci.channels.consumers import CommonLocationBroadcast, LocationBroadcast channel_routing = ProtocolTypeRouter( { @@ -17,7 +20,12 @@ location_broadcast_path, LocationBroadcast.as_asgi(), name="LocationChannel", - ) + ), + path( + common_location_broadcast_path, + CommonLocationBroadcast.as_asgi(), + name="AllLocationChannel", + ), ] ) ) diff --git a/django_loci/channels/base.py b/django_loci/channels/base.py index 0ad608a..3532f9b 100644 --- a/django_loci/channels/base.py +++ b/django_loci/channels/base.py @@ -2,7 +2,8 @@ from channels.generic.websocket import JsonWebsocketConsumer from django.core.exceptions import ValidationError -location_broadcast_path = "ws/loci/location//" +location_broadcast_path = "ws/loci/locations//" +common_location_broadcast_path = "ws/loci/locations/" def _get_object_or_none(model, **kwargs): @@ -61,3 +62,24 @@ def disconnect(self, close_code): async_to_sync(self.channel_layer.group_discard)( self.group_name, self.channel_name ) + + +class BaseCommonLocationBroadcast(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.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 7230113..efc8930 100644 --- a/django_loci/channels/consumers.py +++ b/django_loci/channels/consumers.py @@ -1,6 +1,10 @@ from ..models import Location -from .base import BaseLocationBroadcast +from .base import BaseCommonLocationBroadcast, BaseLocationBroadcast class LocationBroadcast(BaseLocationBroadcast): model = Location + + +class CommonLocationBroadcast(BaseCommonLocationBroadcast): + model = Location diff --git a/django_loci/channels/receivers.py b/django_loci/channels/receivers.py index 31f1689..d1864d2 100644 --- a/django_loci/channels/receivers.py +++ b/django_loci/channels/receivers.py @@ -7,15 +7,40 @@ def update_mobile_location(sender, instance, **kwargs): + """ + Sends WebSocket updates when a location record is updated. + - 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: - group_name = "loci.mobile-location.{0}".format(str(instance.pk)) channel_layer = channels.layers.get_channel_layer() - message = { - "geometry": json.loads(instance.geometry.geojson), - "address": instance.address, - } + + # Send update to location specific group + async_to_sync(channel_layer.group_send)( + f"loci.mobile-location.{instance.pk}", + { + "type": "send_message", + "message": { + "geometry": json.loads(instance.geometry.geojson), + "address": instance.address, + }, + }, + ) + + # Send update to common mobile location group async_to_sync(channel_layer.group_send)( - group_name, {"type": "send_message", "message": 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/static/django-loci/js/loci.js b/django_loci/static/django-loci/js/loci.js index d8d591d..4d2636b 100644 --- a/django_loci/static/django-loci/js/loci.js +++ b/django_loci/static/django-loci/js/loci.js @@ -434,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 cd9ade4..cdd85d4 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 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,26 +39,48 @@ 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} - def _get_communicator(self, request_vars, 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/locations/" + session = None + if user: + session = await self._force_login(user) + return {"pk": pk, "path": path, "session": session} + + 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_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 + ) + @pytest.mark.django_db(transaction=True) def test_object_or_none(self): result = _get_object_or_none(self.location_model, pk=1) @@ -70,8 +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_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() @@ -80,8 +111,18 @@ async def test_consumer_unauthenticated(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_common_location_connect_admin(self): + test_user = await database_sync_to_async(self._create_admin)() + 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() @@ -92,8 +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_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_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() @@ -103,8 +156,8 @@ async def test_consumer_not_staff(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 @@ -119,16 +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( + 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_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_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_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(request_vars, test_user) + communicator = self._get_common_location_communicator(request_vars, test_user) connected, _ = await communicator.connect() assert connected await communicator.disconnect() @@ -137,15 +219,35 @@ async def test_consumer_staff_but_no_change_permission(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"]) + response = await communicator.receive_json_from() + assert response == { + "geometry": {"type": "Point", "coordinates": [12.513124, 41.897903]}, + "address": "Via del Corso, Roma, Italia", + } + await communicator.disconnect() + + @pytest.mark.asyncio + @pytest.mark.django_db(transaction=True) + async def test_common_location_update(self): + test_user = await database_sync_to_async(self._create_admin)() + 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"]) 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() diff --git a/django_loci/tests/testdeviceapp/admin.py b/django_loci/tests/testdeviceapp/admin.py index 2c8852f..2f7c0e3 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 0000000..5ac1fbc --- /dev/null +++ b/django_loci/tests/testdeviceapp/templates/admin/location_broadcast_listener.html @@ -0,0 +1,38 @@ +{% extends "admin/base_site.html" %} + +{% load static %} +{% load i18n %} + +{% block content %} + + +{% endblock content %} + +{% block footer %} +{{ block.super }} + + +{% 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 0000000..e69de29 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 0000000..4ef055a --- /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 0000000..e65ab9d --- /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 e11d4d9..280096f 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"