Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Run with docker `docker compose run binder ./setup.py test` (but you may need to

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.

To only run a selection of the tests, use the `-s` flag like `./setup.py test -s tests.test_some_specific_test`.
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`.

## MySQL support

Expand Down
48 changes: 45 additions & 3 deletions binder/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from decimal import Decimal

from django import forms
from django.db import models
from django.db import models, transaction
from django.db.models import Value
from django.db.models.fields.files import FieldFile, FileField
from django.contrib.postgres.fields import CITextField, ArrayField, DateTimeRangeField as DTRangeField
Expand All @@ -28,6 +28,7 @@
from binder.json import jsonloads

from binder.exceptions import BinderRequestError
from binder.websocket import trigger

from . import history

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


class BinderQuerySet(models.QuerySet):
def update(self, *args, **kwargs):
result = super().update(*args, **kwargs)
self.model.push_default_websocket_update()
return result

def delete(self, *args, **kwargs):
result = super().delete(*args, **kwargs)
self.model.push_default_websocket_update()
return result


class BinderManager(models.Manager):
def get_queryset(self):
return BinderQuerySet(self.model, using=self._db)

def bulk_create(self, *args, **kwargs):
result = super().bulk_create(*args, **kwargs)
self.model.push_default_websocket_update()
return result

def bulk_update(self, *args, **kwargs):
result = super().bulk_update(*args, **kwargs)
self.model.push_default_websocket_update()
return result


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


class BinderModel(models.Model, metaclass=BinderModelBase):
push_websocket_updates_upon_save = False
objects = BinderManager()

def binder_concrete_fields_as_dict(self, skip_deferred_fields=False):
fields = {}
deferred_fields = self.get_deferred_fields()
Expand Down Expand Up @@ -613,10 +643,22 @@ class Meta:
abstract = True
ordering = ['pk']

@classmethod
def push_default_websocket_update(cls):
from binder.views import determine_model_resource_name
if cls.push_websocket_updates_upon_save:
transaction.on_commit(lambda: trigger('', [{ 'auto-updates': determine_model_resource_name(cls.__name__)}]))

def save(self, *args, **kwargs):
self.full_clean() # Never allow saving invalid models!
return super().save(*args, **kwargs)

result = super().save(*args, **kwargs)
self.push_default_websocket_update()
return result

def delete(self, *args, **kwargs):
result = super().delete(*args, **kwargs)
self.push_default_websocket_update()
return result

# This can be overridden in your model when there are special
# validation rules like partial indexes that may need to be
Expand Down
22 changes: 22 additions & 0 deletions binder/permissions/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,28 @@ def get_queryset(self, request):
return self.scope_view(request, queryset)


@classmethod
def get_rooms_for_user(cls, user):
from django.conf import settings

required_permission = cls.model._meta.app_label + '.view_' + cls.model.__name__.lower()
has_required_permission = False
has_full_permissions = False

for low_permission in list(user.get_all_permissions()) + ['default']:
for permission_tuple in settings.BINDER_PERMISSION.get(low_permission, []):
high_permission = permission_tuple[0]
if high_permission == required_permission:
has_required_permission = True
if permission_tuple[1] == 'all':
has_full_permissions = True

rooms = []
if has_required_permission:
rooms.append({ 'auto-updates': cls._model_name() })
if has_full_permissions:
rooms.append({ 'detailed-auto-updates': cls._model_name() })
return rooms

def _require_model_perm(self, perm_type, request, pk=None):
"""
Expand Down
6 changes: 3 additions & 3 deletions binder/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,8 @@ def prefix_q_expression(value, prefix, antiprefix=None, model=None):
children.append((prefix + '__' + child[0], child[1]))
return Q(*children, _connector=value.connector, _negated=value.negated)

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

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

return determine_model_resource_name(cls.model.__name__)


# Use this to instantiate other views you need. It returns a properly initialized view instance.
Expand Down
30 changes: 25 additions & 5 deletions docs/websockets.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ Binder.websockets contains functions to connect with a [high-templar](https://gi

## Flow

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

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

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

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

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.

## Trigger on saves
Since sending websocket updates upon saving models is something we often need, there is a 'shortcut' for this.
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.

```python
class Country(BinderModel):
push_websocket_updates_upon_save = True
name = models.CharField(unique=True, max_length=100)
```
For instance, whenever a `Country` is saved, it will trigger a websocket update to `auto-updates/country` with `data = country.id`.

### Custom object managers
Normally, websocket updates are also sent when an object is bulk created/updated/deleted. This is implemented by using a custom objects `Manager`.
This is usually just an implementation detail, but it can be problematic when your model *also* has its own custom objects `Manager`.
If you want to make bulk updating push websocket notifications, you need to ensure that your custom manager inherits from `binder.models.BinderManager`.

### Forcing websocket updates
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),
you can forcibly send a websocket update by calling the `push_default_websocket_update()` class method on the model.


## Binder setup

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.
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.

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

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

`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.

6 changes: 5 additions & 1 deletion tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,11 @@
# Basic permissions which can be used to override stuff
'testapp.view_country': [

]
],
'testapp.manage_country': [
('testapp.view_country', 'all'),
('testapp.view_city', 'all'),
],
},
'GROUP_PERMISSIONS': {
'admin': [
Expand Down
79 changes: 74 additions & 5 deletions tests/test_websocket.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
from django.test import TestCase, Client
from django.test import TestCase, TransactionTestCase, Client
from django.contrib.auth.models import User
from unittest import mock
from unittest import mock, skipIf
from binder.views import JsonResponse
from binder.websocket import trigger
from .testapp.urls import room_controller
from .testapp.models import Animal, Costume
from .testapp.models import Animal, Costume, Country
import requests
import json
import os
from django.test import override_settings


class MockUser:
def __init__(self, costumes):
def __init__(self, costumes, permissions = []):
self.costumes = costumes
self.permissions = permissions

def get_all_permissions(self):
return self.permissions

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

def test_room_controller_list_rooms_for_user(self):
allowed_rooms = [
{
'auto-updates': 'user'
},
{
'zoo': 'all',
},
Expand Down Expand Up @@ -70,6 +77,69 @@ def test_post_succeeds_when_trigger_fails(self):

self.assertIsNotNone(costume.pk)

def test_auto_update_rooms(self):
user = MockUser([], ['testapp.manage_country'])
rooms = room_controller.list_rooms_for_user(user)

found_it = False
for room in rooms:
if 'auto-updates' in room and room['auto-updates'] == 'city':
found_it = True
self.assertTrue(found_it)

class AutoUpdateTest(TransactionTestCase):

@mock.patch('requests.post', side_effect=mock_post_high_templar)
@override_settings(HIGH_TEMPLAR_URL="http://localhost:8002")
def test_auto_update_trigger(self, mock):
country = Country.objects.create(name='YellowLand')
mock.assert_called_with('http://localhost:8002/trigger/', data=json.dumps({
'data': '',
'rooms': [{ 'auto-updates': 'country' }]
}))
mock.reset_mock()
country.delete()
mock.assert_called_with('http://localhost:8002/trigger/', data=json.dumps({
'data': '',
'rooms': [{ 'auto-updates': 'country' }]
}))

@mock.patch('requests.post', side_effect=mock_post_high_templar)
@override_settings(HIGH_TEMPLAR_URL="http://localhost:8002")
@skipIf(
os.environ.get('BINDER_TEST_MYSQL', '0') != '0',
"Only available with PostgreSQL"
)
def test_bulk_update_trigger(self, mock):
countries = Country.objects.bulk_create([Country(name='YellowLand')])
self.assertEqual(1, len(countries))
country = countries[0]

mock.assert_called_with('http://localhost:8002/trigger/', data=json.dumps({
'data': '',
'rooms': [{ 'auto-updates': 'country' }]
}))
mock.reset_mock()

Country.objects.bulk_update(countries, ['name'])
mock.assert_called_with('http://localhost:8002/trigger/', data=json.dumps({
'data': '',
'rooms': [{ 'auto-updates': 'country' }]
}))
mock.reset_mock()

Country.objects.all().update(name='YellowCountry')
mock.assert_called_with('http://localhost:8002/trigger/', data=json.dumps({
'data': '',
'rooms': [{ 'auto-updates': 'country' }]
}))
mock.reset_mock()

Country.objects.filter(id=country.pk).delete()
mock.assert_called_with('http://localhost:8002/trigger/', data=json.dumps({
'data': '',
'rooms': [{ 'auto-updates': 'country' }]
}))

class TriggerConnectionCloseTest(TestCase):
@override_settings(
Expand All @@ -92,4 +162,3 @@ def test_trigger_calls_connection_close(self, mock_connection_class):
trigger(data, rooms)

mock_connection.close.assert_called_once()

1 change: 1 addition & 0 deletions tests/testapp/models/country.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@


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