Skip to content

Commit fd5c56b

Browse files
committed
T50470: push websocket updates upon saving models
1 parent 0c2f669 commit fd5c56b

File tree

7 files changed

+75
-16
lines changed

7 files changed

+75
-16
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Run with docker `docker compose run binder ./setup.py test` (but you may need to
1515

1616
The tests are set up in such a way that there is no need to keep migration files. The setup procedure in `tests/__init__.py` handles the preparation of the database by directly calling some build-in Django commands.
1717

18-
To only run a selection of the tests, use the `-s` flag like `./setup.py test -s tests.test_some_specific_test`.
18+
To only run a selection of the tests, use the `-s` flag like `docker compose run binder ./setup.py test -s tests.test_some_specific_test`.
1919

2020
## MySQL support
2121

binder/models.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from decimal import Decimal
1010

1111
from django import forms
12-
from django.db import models
12+
from django.db import models, transaction
1313
from django.db.models import Value
1414
from django.db.models.fields.files import FieldFile, FileField
1515
from django.contrib.postgres.fields import CITextField, ArrayField, DateTimeRangeField as DTRangeField
@@ -28,6 +28,7 @@
2828
from binder.json import jsonloads
2929

3030
from binder.exceptions import BinderRequestError
31+
from binder.websocket import trigger
3132

3233
from . import history
3334

@@ -458,6 +459,8 @@ def __new__(cls, name, bases, attrs):
458459

459460

460461
class BinderModel(models.Model, metaclass=BinderModelBase):
462+
push_websocket_updates_upon_save = False
463+
461464
def binder_concrete_fields_as_dict(self, skip_deferred_fields=False):
462465
fields = {}
463466
deferred_fields = self.get_deferred_fields()
@@ -614,9 +617,19 @@ class Meta:
614617
ordering = ['pk']
615618

616619
def save(self, *args, **kwargs):
620+
from binder.views import determine_model_resource_name
617621
self.full_clean() # Never allow saving invalid models!
618-
return super().save(*args, **kwargs)
619-
622+
result = super().save(*args, **kwargs)
623+
if self.push_websocket_updates_upon_save:
624+
transaction.on_commit(lambda: trigger(self.pk, [{ 'auto-updates': determine_model_resource_name(self.__class__.__name__)}]))
625+
return result
626+
627+
def delete(self, *args, **kwargs):
628+
from binder.views import determine_model_resource_name
629+
result = super().delete(*args, **kwargs)
630+
if self.push_websocket_updates_upon_save:
631+
transaction.on_commit(lambda: trigger(self.pk, [{ 'auto-updates': determine_model_resource_name(self.__class__.__name__)}]))
632+
return result
620633

621634
# This can be overridden in your model when there are special
622635
# validation rules like partial indexes that may need to be

binder/permissions/views.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,13 @@ def get_queryset(self, request):
190190
return self.scope_view(request, queryset)
191191

192192

193+
@classmethod
194+
def get_rooms_for_user(cls, user):
195+
if user.has_perm(cls.model._meta.app_label + '.view_' + cls.model.__name__.lower()):
196+
return [{ 'auto-updates': cls._model_name() }]
197+
else:
198+
return []
199+
193200

194201
def _require_model_perm(self, perm_type, request, pk=None):
195202
"""

binder/views.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,8 @@ def prefix_q_expression(value, prefix, antiprefix=None, model=None):
342342
children.append((prefix + '__' + child[0], child[1]))
343343
return Q(*children, _connector=value.connector, _negated=value.negated)
344344

345+
def determine_model_resource_name(mn: str):
346+
return ''.join((x + '_' if x.islower() and y.isupper() else x.lower() for x, y in zip(mn, mn[1:] + 'x')))
345347

346348
class ModelView(View):
347349
# Model this is a view for. Use None for views not tied to a particular model.
@@ -578,9 +580,7 @@ def get_field_filter(self, field_class, reset=False):
578580
# Like model._meta.model_name, except it converts camelcase to underscores
579581
@classmethod
580582
def _model_name(cls):
581-
mn = cls.model.__name__
582-
return ''.join((x + '_' if x.islower() and y.isupper() else x.lower() for x, y in zip(mn, mn[1:] + 'x')))
583-
583+
return determine_model_resource_name(cls.model.__name__)
584584

585585

586586
# Use this to instantiate other views you need. It returns a properly initialized view instance.

docs/websockets.md

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ Binder.websockets contains functions to connect with a [high-templar](https://gi
44

55
## Flow
66

7-
The client = a web/native/whatever frontend
8-
The websocket server = a high-templar instance
7+
The client = a web/native/whatever frontend
8+
The websocket server = a high-templar instance
99
The binder app = a server created with django-binder
1010

1111
- The client needs live updates of certain models.
@@ -24,7 +24,7 @@ The scoping of subscriptions is done through rooms. Roomnames are dictionaries.
2424

2525
There is a chat application for a company. A manager can only view messages of a single location.
2626

27-
The allowed_rooms of a manager of the eindhoven branch could look like
27+
The allowed_rooms of a manager of the eindhoven branch could look like
2828
```
2929
[{'location': 'Eindhoven'}]
3030
```
@@ -43,9 +43,21 @@ Note: this doesn't mean a client can subscribe to room: `{'location': '*'}` and
4343

4444
If you do really need a room with messages from all locations, just trigger twice: once in the location specific room and one in the location: * room.
4545

46+
## Trigger on saves
47+
Since sending websocket updates upon saving models is something we often need, there is a 'shortcut' for this.
48+
If you set `push_websocket_updates_upon_save` to `True` in a model, it will automatically send websocket updates whenever it is saved or deleted.
49+
50+
```python
51+
class Country(BinderModel):
52+
push_websocket_updates_upon_save = True
53+
name = models.CharField(unique=True, max_length=100)
54+
```
55+
For instance, whenever a `Country` is saved, it will trigger a websocket update to `auto-updates/country` with `data = country.id`.
56+
57+
4658
## Binder setup
4759

48-
The high-templar instance is agnostic of the authentication/datamodel/permissions. The authentication is done by the proxy to /api/bootstrap. The datamodel / permission stuff is all done through rooms and the data that gets sent through it.
60+
The high-templar instance is agnostic of the authentication/datamodel/permissions. The authentication is done by the proxy to /api/bootstrap. The datamodel / permission stuff is all done through rooms and the data that gets sent through it.
4961

5062
`binder.websocket` provides 2 helpers for communicating with high-templar.
5163

@@ -74,4 +86,3 @@ The RoomController checks every descendant of the ModelView and looks for a `@c
7486
### Trigger
7587

7688
`binder.websocket` provides a `trigger` to the high_templar instance using a POST request. The url for this request is `getattr(settings, 'HIGH_TEMPLAR_URL', 'http://localhost:8002')`. It needs `data, rooms` as args, the data which will be sent in the publish and the rooms it will be publishes to.
77-

tests/test_websocket.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
1-
from django.test import TestCase, Client
1+
from django.test import TestCase, TransactionTestCase, Client
22
from django.contrib.auth.models import User
33
from unittest import mock
44
from binder.views import JsonResponse
55
from binder.websocket import trigger
66
from .testapp.urls import room_controller
7-
from .testapp.models import Animal, Costume
7+
from .testapp.models import Animal, Costume, Country
88
import requests
99
import json
1010
from django.test import override_settings
1111

1212

1313
class MockUser:
14-
def __init__(self, costumes):
14+
def __init__(self, costumes, permission = ''):
1515
self.costumes = costumes
16+
self.permission = permission
1617

18+
def has_perm(self, permission: str):
19+
return permission == self.permission
1720

1821
def mock_post_high_templar(*args, **kwargs):
1922
return JsonResponse({'ok': True})
@@ -70,6 +73,31 @@ def test_post_succeeds_when_trigger_fails(self):
7073

7174
self.assertIsNotNone(costume.pk)
7275

76+
def test_auto_update_rooms(self):
77+
user = MockUser([], 'testapp.view_city')
78+
rooms = room_controller.list_rooms_for_user(user)
79+
80+
found_it = False
81+
for room in rooms:
82+
if 'auto-updates' in room and room['auto-updates'] == 'city':
83+
found_it = True
84+
self.assertTrue(found_it)
85+
86+
class AutoUpdateTest(TransactionTestCase):
87+
@mock.patch('requests.post', side_effect=mock_post_high_templar)
88+
@override_settings(HIGH_TEMPLAR_URL="http://localhost:8002")
89+
def test_auto_update_trigger(self, mock):
90+
country = Country.objects.create(name='YellowLand')
91+
mock.assert_called_with('http://localhost:8002/trigger/', data=json.dumps({
92+
'data': country.pk,
93+
'rooms': [{ 'auto-updates': 'country' }]
94+
}))
95+
mock.reset_mock()
96+
country.delete()
97+
mock.assert_called_with('http://localhost:8002/trigger/', data=json.dumps({
98+
'data': country.pk,
99+
'rooms': [{ 'auto-updates': 'country' }]
100+
}))
73101

74102
class TriggerConnectionCloseTest(TestCase):
75103
@override_settings(
@@ -92,4 +120,3 @@ def test_trigger_calls_connection_close(self, mock_connection_class):
92120
trigger(data, rooms)
93121

94122
mock_connection.close.assert_called_once()
95-

tests/testapp/models/country.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@
33

44

55
class Country(BinderModel):
6+
push_websocket_updates_upon_save = True
67
name = models.CharField(unique=True, max_length=100)

0 commit comments

Comments
 (0)