Skip to content

Commit 4f38773

Browse files
authored
[feature] Added a WebSocket endpoint for broadcasting all location updates #191
- Added a new Channels consumer at ``/ws/loci/location/all/`` to broadcast updates for all locations in a single feed. - Implemented ``BaseCommonLocationBroadcast`` and ``CommonLocationBroadcast``. - Updated signal receivers to publish to both per-location and common broadcast groups. - Switched ``CHANNEL_LAYERS`` in the test project to use Redis. - Updated local tooling and CI to include Redis service. Closes #191
1 parent e1755ea commit 4f38773

File tree

15 files changed

+448
-80
lines changed

15 files changed

+448
-80
lines changed

.github/workflows/ci.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ jobs:
1313
name: Python==${{ matrix.python-version }} | ${{ matrix.django-version }}
1414
runs-on: ubuntu-24.04
1515

16+
services:
17+
redis:
18+
image: redis
19+
ports:
20+
- 6379:6379
21+
1622
strategy:
1723
fail-fast: false
1824
matrix:

README.rst

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ Reusable django-app for storing GIS and indoor coordinates of objects.
5353
Dependencies
5454
------------
5555

56-
- Python >= 3.8
56+
- Python >= 3.10
5757
- GeoDjango (`see GeoDjango Install Instructions
5858
<https://docs.djangoproject.com/en/dev/ref/contrib/gis/install/#requirements>`_)
5959
- One of the databases supported by GeoDjango
@@ -67,7 +67,7 @@ django-loci Python version
6767
0.3 - 0.4 >=3.6
6868
1.0 >=3.7
6969
1.1 >=3.8
70-
dev >=3.8
70+
dev >=3.10
7171
=========== ==============
7272

7373
Install stable version from pypi
@@ -142,7 +142,10 @@ configuration can be:
142142
ASGI_APPLICATION = "django_loci.channels.asgi.channel_routing"
143143
CHANNEL_LAYERS = {
144144
"default": {
145-
"BACKEND": "channels.layers.InMemoryChannelLayer",
145+
"BACKEND": "channels_redis.core.RedisChannelLayer",
146+
"CONFIG": {
147+
"hosts": [("127.0.0.1", 6379)],
148+
},
146149
},
147150
}
148151
@@ -482,6 +485,17 @@ Extend the channel consumer of django-loci in this way:
482485
class LocationBroadcast(BaseLocationBroadcast):
483486
model = Location
484487
488+
Extend the broadcast consumer for all locations:
489+
490+
.. code-block:: python
491+
492+
from django_loci.channels.base import BaseCommonLocationBroadcast
493+
from ..models import Location # your own location model
494+
495+
496+
class CommonLocationBroadcast(BaseCommonLocationBroadcast):
497+
model = Location
498+
485499
Extending AppConfig
486500
~~~~~~~~~~~~~~~~~~~
487501

@@ -524,6 +538,12 @@ Install test requirements:
524538
525539
pip install -r requirements-test.txt
526540
541+
Launch Redis:
542+
543+
.. code-block:: shell
544+
545+
docker compose up -d redis
546+
527547
Create database:
528548

529549
.. code-block:: shell

django_loci/channels/asgi.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@
44
from django.core.asgi import get_asgi_application
55
from django.urls import path
66

7-
from django_loci.channels.base import location_broadcast_path
8-
from django_loci.channels.consumers import LocationBroadcast
7+
from django_loci.channels.base import (
8+
common_location_broadcast_path,
9+
location_broadcast_path,
10+
)
11+
from django_loci.channels.consumers import CommonLocationBroadcast, LocationBroadcast
912

1013
channel_routing = ProtocolTypeRouter(
1114
{
@@ -17,7 +20,12 @@
1720
location_broadcast_path,
1821
LocationBroadcast.as_asgi(),
1922
name="LocationChannel",
20-
)
23+
),
24+
path(
25+
common_location_broadcast_path,
26+
CommonLocationBroadcast.as_asgi(),
27+
name="AllLocationChannel",
28+
),
2129
]
2230
)
2331
)

django_loci/channels/base.py

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from django.core.exceptions import ValidationError
44

55
location_broadcast_path = "ws/loci/location/<uuid:pk>/"
6+
common_location_broadcast_path = "ws/loci/location/"
67

78

89
def _get_object_or_none(model, **kwargs):
@@ -14,11 +15,15 @@ def _get_object_or_none(model, **kwargs):
1415

1516
class BaseLocationBroadcast(JsonWebsocketConsumer):
1617
"""
17-
Notifies that the coordinates of a location have changed
18-
to authorized users (superusers or organization operators)
18+
Base WebSocket consumer for broadcasting location coordinate changes
19+
to authorized users (superusers or organization operators).
1920
"""
2021

2122
def connect(self):
23+
"""
24+
Handle WebSocket connection: authenticate user, validate location,
25+
and join the location-specific broadcast group.
26+
"""
2227
self.pk = None
2328
try:
2429
user = self.scope["user"]
@@ -41,6 +46,10 @@ def connect(self):
4146
)
4247

4348
def is_authorized(self, user, location):
49+
"""
50+
Check if the user has permission to receive location broadcasts.
51+
Requires authentication and change or view permissions on the location.
52+
"""
4453
perm = "{0}.change_location".format(self.model._meta.app_label)
4554
# allow users with view permission
4655
readperm = "{0}.view_location".format(self.model._meta.app_label)
@@ -49,15 +58,45 @@ def is_authorized(self, user, location):
4958
return authenticated and (user.is_superuser or (user.is_staff and is_permitted))
5059

5160
def send_message(self, event):
61+
"""
62+
Send JSON event data to the connected WebSocket client.
63+
"""
5264
self.send_json(event["message"])
5365

5466
def disconnect(self, close_code):
5567
"""
56-
Perform things on connection close
68+
Handle cleanup on WebSocket disconnection.
5769
"""
5870
# The group_name is set only when the connection is accepted.
5971
# Remove the user from the group, if it exists.
6072
if hasattr(self, "group_name"):
6173
async_to_sync(self.channel_layer.group_discard)(
6274
self.group_name, self.channel_name
6375
)
76+
77+
78+
class BaseCommonLocationBroadcast(BaseLocationBroadcast):
79+
80+
def connect(self):
81+
"""
82+
Override connect to handle subscription to all locations
83+
without requiring a specific location PK.
84+
"""
85+
try:
86+
user = self.scope["user"]
87+
except KeyError:
88+
self.close()
89+
else:
90+
if not self.is_authorized(user, None):
91+
self.close()
92+
return
93+
self.accept()
94+
self.join_groups(user)
95+
96+
def join_groups(self, user):
97+
"""
98+
Subscribe to broadcast groups.
99+
Subclasses can override to add user-specific groups (using the ``user`` argument).
100+
"""
101+
self.group_name = "loci.mobile-location.common"
102+
async_to_sync(self.channel_layer.group_add)(self.group_name, self.channel_name)

django_loci/channels/consumers.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
from ..models import Location
2-
from .base import BaseLocationBroadcast
2+
from .base import BaseCommonLocationBroadcast, BaseLocationBroadcast
33

44

55
class LocationBroadcast(BaseLocationBroadcast):
66
model = Location
7+
8+
9+
class CommonLocationBroadcast(BaseCommonLocationBroadcast):
10+
model = Location

django_loci/channels/receivers.py

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,40 @@
77

88

99
def update_mobile_location(sender, instance, **kwargs):
10+
"""
11+
Sends WebSocket updates when a location record is updated.
12+
- Sends a message to the location specific group.
13+
- Sends a message to a common group for tracking all mobile location updates.
14+
"""
1015
if not kwargs.get("created") and instance.geometry:
11-
group_name = "loci.mobile-location.{0}".format(str(instance.pk))
1216
channel_layer = channels.layers.get_channel_layer()
13-
message = {
14-
"geometry": json.loads(instance.geometry.geojson),
15-
"address": instance.address,
16-
}
17+
18+
# Send update to location specific group
19+
async_to_sync(channel_layer.group_send)(
20+
f"loci.mobile-location.{instance.pk}",
21+
{
22+
"type": "send_message",
23+
"message": {
24+
"geometry": json.loads(instance.geometry.geojson),
25+
"address": instance.address,
26+
},
27+
},
28+
)
29+
30+
# Send update to common mobile location group
1731
async_to_sync(channel_layer.group_send)(
18-
group_name, {"type": "send_message", "message": message}
32+
"loci.mobile-location.common",
33+
{
34+
"type": "send_message",
35+
"message": {
36+
"id": str(instance.pk),
37+
"geometry": json.loads(instance.geometry.geojson),
38+
"address": instance.address,
39+
"name": instance.name,
40+
"type": instance.type,
41+
"is_mobile": instance.is_mobile,
42+
},
43+
},
1944
)
2045

2146

django_loci/tests/__init__.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@
22
Reusable test helpers
33
"""
44

5+
import importlib
56
import os
67

8+
from channels.db import database_sync_to_async
9+
from channels.testing import WebsocketCommunicator
710
from django.conf import settings
11+
from django.contrib.auth import login
812
from django.core.files.uploadedfile import SimpleUploadedFile
13+
from django.http.request import HttpRequest
914

1015

1116
class TestLociMixin(object):
@@ -137,3 +142,75 @@ def add_url(self):
137142
@property
138143
def change_url(self):
139144
return "{0}_change".format(self._get_url_prefix())
145+
146+
147+
class TestChannelsMixin(object):
148+
149+
async def _force_login(self, user, backend=None):
150+
engine = importlib.import_module(settings.SESSION_ENGINE)
151+
request = HttpRequest()
152+
request.session = engine.SessionStore()
153+
await database_sync_to_async(login)(request, user, backend)
154+
await database_sync_to_async(request.session.save)()
155+
return request.session
156+
157+
async def _get_location_request_dict(self, path, pk=None, user=None):
158+
if not pk:
159+
location = await database_sync_to_async(self._create_location)(
160+
is_mobile=True
161+
)
162+
await database_sync_to_async(self._create_object_location)(
163+
location=location
164+
)
165+
pk = location.pk
166+
session = None
167+
if user:
168+
session = await self._force_login(user)
169+
return {"pk": pk, "path": path, "session": session}
170+
171+
async def _get_specific_location_request_dict(self, pk=None, user=None):
172+
result = await self._get_location_request_dict(
173+
path="/ws/loci/location/{0}/", pk=pk, user=user
174+
)
175+
result["path"] = result["path"].format(result["pk"])
176+
return result
177+
178+
async def _get_common_location_request_dict(self, pk=None, user=None):
179+
return await self._get_location_request_dict(
180+
path="/ws/loci/location/", pk=pk, user=user
181+
)
182+
183+
def _get_location_communicator(
184+
self, consumer, request_vars, user=None, include_pk=False
185+
):
186+
communicator = WebsocketCommunicator(consumer.as_asgi(), request_vars["path"])
187+
if user:
188+
scope = {
189+
"user": user,
190+
"session": request_vars["session"],
191+
}
192+
if include_pk:
193+
scope["url_route"] = {"kwargs": {"pk": request_vars["pk"]}}
194+
communicator.scope.update(scope)
195+
return communicator
196+
197+
def _get_specific_location_communicator(self, request_vars, user=None):
198+
return self._get_location_communicator(
199+
consumer=self.location_consumer,
200+
request_vars=request_vars,
201+
user=user,
202+
include_pk=True,
203+
)
204+
205+
def _get_common_location_communicator(self, request_vars, user=None):
206+
return self._get_location_communicator(
207+
consumer=self.common_location_consumer,
208+
request_vars=request_vars,
209+
user=user,
210+
include_pk=False,
211+
)
212+
213+
async def _save_location(self, pk):
214+
loc = await self.location_model.objects.aget(pk=pk)
215+
loc.geometry = "POINT (12.513124 41.897903)"
216+
await loc.asave()

0 commit comments

Comments
 (0)