Skip to content

Commit 4d3eab9

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

File tree

8 files changed

+95
-17
lines changed

8 files changed

+95
-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: 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: 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: 34 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,31 @@ 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+
@mock.patch('requests.post', side_effect=mock_post_high_templar)
91+
@override_settings(HIGH_TEMPLAR_URL="http://localhost:8002")
92+
def test_auto_update_trigger(self, mock):
93+
country = Country.objects.create(name='YellowLand')
94+
mock.assert_called_with('http://localhost:8002/trigger/', data=json.dumps({
95+
'data': country.pk,
96+
'rooms': [{ 'auto-updates': 'country' }]
97+
}))
98+
mock.reset_mock()
99+
country.delete()
100+
mock.assert_called_with('http://localhost:8002/trigger/', data=json.dumps({
101+
'data': country.pk,
102+
'rooms': [{ 'auto-updates': 'country' }]
103+
}))
73104

74105
class TriggerConnectionCloseTest(TestCase):
75106
@override_settings(
@@ -92,4 +123,3 @@ def test_trigger_calls_connection_close(self, mock_connection_class):
92123
trigger(data, rooms)
93124

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