Skip to content

Commit af85062

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

File tree

8 files changed

+172
-18
lines changed

8 files changed

+172
-18
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: 44 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

@@ -440,6 +441,32 @@ def clean_value(self, qualifier, v):
440441
return jsonloads(bytes(v, 'utf-8'))
441442

442443

444+
class BinderQuerySet(models.QuerySet):
445+
def update(self, *args, **kwargs):
446+
result = super().update(*args, **kwargs)
447+
self.model().push_default_websocket_update()
448+
return result
449+
450+
def delete(self, *args, **kwargs):
451+
result = super().delete(*args, **kwargs)
452+
self.model().push_default_websocket_update()
453+
return result
454+
455+
456+
class BinderManager(models.Manager):
457+
def get_queryset(self):
458+
return BinderQuerySet(self.model, using=self._db)
459+
460+
def bulk_create(self, *args, **kwargs):
461+
result = super().bulk_create(*args, **kwargs)
462+
self.model().push_default_websocket_update()
463+
return result
464+
465+
def bulk_update(self, *args, **kwargs):
466+
result = super().bulk_update(*args, **kwargs)
467+
self.model().push_default_websocket_update()
468+
return result
469+
443470

444471
class BinderModelBase(models.base.ModelBase):
445472
def __new__(cls, name, bases, attrs):
@@ -458,6 +485,9 @@ def __new__(cls, name, bases, attrs):
458485

459486

460487
class BinderModel(models.Model, metaclass=BinderModelBase):
488+
push_websocket_updates_upon_save = False
489+
objects = BinderManager()
490+
461491
def binder_concrete_fields_as_dict(self, skip_deferred_fields=False):
462492
fields = {}
463493
deferred_fields = self.get_deferred_fields()
@@ -613,10 +643,21 @@ class Meta:
613643
abstract = True
614644
ordering = ['pk']
615645

646+
def push_default_websocket_update(self):
647+
from binder.views import determine_model_resource_name
648+
if self.push_websocket_updates_upon_save:
649+
transaction.on_commit(lambda: trigger('', [{ 'auto-updates': determine_model_resource_name(self.__class__.__name__)}]))
650+
616651
def save(self, *args, **kwargs):
617652
self.full_clean() # Never allow saving invalid models!
618-
return super().save(*args, **kwargs)
619-
653+
result = super().save(*args, **kwargs)
654+
self.push_default_websocket_update()
655+
return result
656+
657+
def delete(self, *args, **kwargs):
658+
result = super().delete(*args, **kwargs)
659+
self.push_default_websocket_update()
660+
return result
620661

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

binder/permissions/views.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,25 @@ 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+
from django.conf import settings
196+
197+
required_permission = cls.model._meta.app_label + '.view_' + cls.model.__name__.lower()
198+
has_required_permission = False
199+
200+
for low_permission in list(user.get_all_permissions()) + ['default']:
201+
for permission_tuple in settings.BINDER_PERMISSION.get(low_permission, []):
202+
high_permission = permission_tuple[0]
203+
if high_permission == required_permission:
204+
has_required_permission = True
205+
break
206+
207+
if has_required_permission:
208+
return [{ 'auto-updates': cls._model_name() }]
209+
else:
210+
return []
211+
193212

194213
def _require_model_perm(self, perm_type, request, pk=None):
195214
"""

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: 25 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,30 @@ 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+
### Custom object managers
58+
Normally, websocket updates are also sent when an object is bulk created/updated/deleted. This is implemented by using a custom objects `Manager`.
59+
This is usually just an implementation detail, but it can be problematic when your model *also* has its own custom objects `Manager`.
60+
If you want to make bulk updating push websocket notifications, you need to ensure that your custom manager inherits from `binder.models.BinderManager`.
61+
62+
### Forcing websocket updates
63+
If you want stores to re-fetch your objects, but you haven't saved them directly (e.g. when you changed related objects or annotation values),
64+
you can forcibly send a websocket update by calling the `push_default_websocket_update()` method on an instance of your model.
65+
66+
4667
## Binder setup
4768

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.
69+
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.
4970

5071
`binder.websocket` provides 2 helpers for communicating with high-templar.
5172

@@ -74,4 +95,3 @@ The RoomController checks every descendant of the ModelView and looks for a `@c
7495
### Trigger
7596

7697
`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/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,11 @@
9999
# Basic permissions which can be used to override stuff
100100
'testapp.view_country': [
101101

102-
]
102+
],
103+
'testapp.manage_country': [
104+
('testapp.view_country', 'all'),
105+
('testapp.view_city', 'all'),
106+
],
103107
},
104108
'GROUP_PERMISSIONS': {
105109
'admin': [

tests/test_websocket.py

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
1-
from django.test import TestCase, Client
1+
from django.test import TestCase, TransactionTestCase, Client
22
from django.contrib.auth.models import User
3-
from unittest import mock
3+
from unittest import mock, skipIf
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
10+
import os
1011
from django.test import override_settings
1112

1213

1314
class MockUser:
14-
def __init__(self, costumes):
15+
def __init__(self, costumes, permissions = []):
1516
self.costumes = costumes
17+
self.permissions = permissions
1618

19+
def get_all_permissions(self):
20+
return self.permissions
1721

1822
def mock_post_high_templar(*args, **kwargs):
1923
return JsonResponse({'ok': True})
@@ -31,6 +35,9 @@ def setUp(self):
3135

3236
def test_room_controller_list_rooms_for_user(self):
3337
allowed_rooms = [
38+
{
39+
'auto-updates': 'user'
40+
},
3441
{
3542
'zoo': 'all',
3643
},
@@ -70,6 +77,69 @@ def test_post_succeeds_when_trigger_fails(self):
7077

7178
self.assertIsNotNone(costume.pk)
7279

80+
def test_auto_update_rooms(self):
81+
user = MockUser([], ['testapp.manage_country'])
82+
rooms = room_controller.list_rooms_for_user(user)
83+
84+
found_it = False
85+
for room in rooms:
86+
if 'auto-updates' in room and room['auto-updates'] == 'city':
87+
found_it = True
88+
self.assertTrue(found_it)
89+
90+
class AutoUpdateTest(TransactionTestCase):
91+
92+
@mock.patch('requests.post', side_effect=mock_post_high_templar)
93+
@override_settings(HIGH_TEMPLAR_URL="http://localhost:8002")
94+
def test_auto_update_trigger(self, mock):
95+
country = Country.objects.create(name='YellowLand')
96+
mock.assert_called_with('http://localhost:8002/trigger/', data=json.dumps({
97+
'data': '',
98+
'rooms': [{ 'auto-updates': 'country' }]
99+
}))
100+
mock.reset_mock()
101+
country.delete()
102+
mock.assert_called_with('http://localhost:8002/trigger/', data=json.dumps({
103+
'data': '',
104+
'rooms': [{ 'auto-updates': 'country' }]
105+
}))
106+
107+
@mock.patch('requests.post', side_effect=mock_post_high_templar)
108+
@override_settings(HIGH_TEMPLAR_URL="http://localhost:8002")
109+
@skipIf(
110+
os.environ.get('BINDER_TEST_MYSQL', '0') != '0',
111+
"Only available with PostgreSQL"
112+
)
113+
def test_bulk_update_trigger(self, mock):
114+
countries = Country.objects.bulk_create([Country(name='YellowLand')])
115+
self.assertEqual(1, len(countries))
116+
country = countries[0]
117+
118+
mock.assert_called_with('http://localhost:8002/trigger/', data=json.dumps({
119+
'data': '',
120+
'rooms': [{ 'auto-updates': 'country' }]
121+
}))
122+
mock.reset_mock()
123+
124+
Country.objects.bulk_update(countries, ['name'])
125+
mock.assert_called_with('http://localhost:8002/trigger/', data=json.dumps({
126+
'data': '',
127+
'rooms': [{ 'auto-updates': 'country' }]
128+
}))
129+
mock.reset_mock()
130+
131+
Country.objects.all().update(name='YellowCountry')
132+
mock.assert_called_with('http://localhost:8002/trigger/', data=json.dumps({
133+
'data': '',
134+
'rooms': [{ 'auto-updates': 'country' }]
135+
}))
136+
mock.reset_mock()
137+
138+
Country.objects.filter(id=country.pk).delete()
139+
mock.assert_called_with('http://localhost:8002/trigger/', data=json.dumps({
140+
'data': '',
141+
'rooms': [{ 'auto-updates': 'country' }]
142+
}))
73143

74144
class TriggerConnectionCloseTest(TestCase):
75145
@override_settings(
@@ -92,4 +162,3 @@ def test_trigger_calls_connection_close(self, mock_connection_class):
92162
trigger(data, rooms)
93163

94164
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)