Skip to content

Commit 9bae7ea

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

File tree

8 files changed

+139
-17
lines changed

8 files changed

+139
-17
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: 34 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,22 @@ def clean_value(self, qualifier, v):
440441
return jsonloads(bytes(v, 'utf-8'))
441442

442443

444+
class BinderManager(models.Manager):
445+
def bulk_create(self, *args, **kwargs):
446+
result = super().bulk_create(*args, **kwargs)
447+
self.model().push_default_websocket_update()
448+
return result
449+
450+
def bulk_update(self, *args, **kwargs):
451+
result = super().bulk_update(*args, **kwargs)
452+
self.model().push_default_websocket_update()
453+
return result
454+
455+
def delete(self, *args, **kwargs):
456+
result = super().delete(*args, **kwargs)
457+
self.model().push_default_websocket_update()
458+
return result
459+
443460

444461
class BinderModelBase(models.base.ModelBase):
445462
def __new__(cls, name, bases, attrs):
@@ -458,6 +475,9 @@ def __new__(cls, name, bases, attrs):
458475

459476

460477
class BinderModel(models.Model, metaclass=BinderModelBase):
478+
push_websocket_updates_upon_save = False
479+
objects = BinderManager()
480+
461481
def binder_concrete_fields_as_dict(self, skip_deferred_fields=False):
462482
fields = {}
463483
deferred_fields = self.get_deferred_fields()
@@ -613,10 +633,21 @@ class Meta:
613633
abstract = True
614634
ordering = ['pk']
615635

636+
def push_default_websocket_update(self):
637+
from binder.views import determine_model_resource_name
638+
if self.push_websocket_updates_upon_save:
639+
transaction.on_commit(lambda: trigger('', [{ 'auto-updates': determine_model_resource_name(self.__class__.__name__)}]))
640+
616641
def save(self, *args, **kwargs):
617642
self.full_clean() # Never allow saving invalid models!
618-
return super().save(*args, **kwargs)
619-
643+
result = super().save(*args, **kwargs)
644+
self.push_default_websocket_update()
645+
return result
646+
647+
def delete(self, *args, **kwargs):
648+
result = super().delete(*args, **kwargs)
649+
self.push_default_websocket_update()
650+
return result
620651

621652
# This can be overridden in your model when there are special
622653
# 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: 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/__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: 60 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, permissions = []):
1515
self.costumes = costumes
16+
self.permissions = permissions
1617

18+
def get_all_permissions(self):
19+
return self.permissions
1720

1821
def mock_post_high_templar(*args, **kwargs):
1922
return JsonResponse({'ok': True})
@@ -31,6 +34,9 @@ def setUp(self):
3134

3235
def test_room_controller_list_rooms_for_user(self):
3336
allowed_rooms = [
37+
{
38+
'auto-updates': 'user'
39+
},
3440
{
3541
'zoo': 'all',
3642
},
@@ -70,6 +76,57 @@ def test_post_succeeds_when_trigger_fails(self):
7076

7177
self.assertIsNotNone(costume.pk)
7278

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

74131
class TriggerConnectionCloseTest(TestCase):
75132
@override_settings(
@@ -92,4 +149,3 @@ def test_trigger_calls_connection_close(self, mock_connection_class):
92149
trigger(data, rooms)
93150

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