From bb7915f8ce686dc55e8bef0f3537d0fb27ca6587 Mon Sep 17 00:00:00 2001 From: knokko Date: Mon, 15 Sep 2025 10:23:28 +0200 Subject: [PATCH 1/2] T50470: push websocket updates upon saving models --- README.md | 2 +- binder/models.py | 48 ++++++++++++++++++-- binder/permissions/views.py | 19 ++++++++ binder/views.py | 6 +-- docs/websockets.md | 30 ++++++++++--- tests/__init__.py | 6 ++- tests/test_websocket.py | 79 ++++++++++++++++++++++++++++++--- tests/testapp/models/country.py | 1 + 8 files changed, 173 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index ca446a74..3fdb9d49 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/binder/models.py b/binder/models.py index 5c1ddbbe..55b85f37 100644 --- a/binder/models.py +++ b/binder/models.py @@ -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 @@ -28,6 +28,7 @@ from binder.json import jsonloads from binder.exceptions import BinderRequestError +from binder.websocket import trigger from . import history @@ -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): @@ -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() @@ -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 diff --git a/binder/permissions/views.py b/binder/permissions/views.py index 9f0b8e6e..73be865d 100644 --- a/binder/permissions/views.py +++ b/binder/permissions/views.py @@ -190,6 +190,25 @@ 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 + + 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 + break + + if has_required_permission: + return [{ 'auto-updates': cls._model_name() }] + else: + return [] + def _require_model_perm(self, perm_type, request, pk=None): """ diff --git a/binder/views.py b/binder/views.py index fea5de50..85e1f43d 100644 --- a/binder/views.py +++ b/binder/views.py @@ -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. @@ -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. diff --git a/docs/websockets.md b/docs/websockets.md index 9186c438..eadbf2ca 100644 --- a/docs/websockets.md +++ b/docs/websockets.md @@ -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. @@ -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'}] ``` @@ -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. @@ -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. - diff --git a/tests/__init__.py b/tests/__init__.py index 0d8e9148..491e9b60 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -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': [ diff --git a/tests/test_websocket.py b/tests/test_websocket.py index 1bbf8464..f5056c15 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -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}) @@ -31,6 +35,9 @@ def setUp(self): def test_room_controller_list_rooms_for_user(self): allowed_rooms = [ + { + 'auto-updates': 'user' + }, { 'zoo': 'all', }, @@ -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( @@ -92,4 +162,3 @@ def test_trigger_calls_connection_close(self, mock_connection_class): trigger(data, rooms) mock_connection.close.assert_called_once() - diff --git a/tests/testapp/models/country.py b/tests/testapp/models/country.py index 67977c72..67d53a0b 100644 --- a/tests/testapp/models/country.py +++ b/tests/testapp/models/country.py @@ -3,4 +3,5 @@ class Country(BinderModel): + push_websocket_updates_upon_save = True name = models.CharField(unique=True, max_length=100) From 8f2559c34783b2b473a1fe10705eea407d839e63 Mon Sep 17 00:00:00 2001 From: knokko Date: Thu, 13 Nov 2025 13:46:32 +0100 Subject: [PATCH 2/2] wip stuff --- binder/permissions/views.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/binder/permissions/views.py b/binder/permissions/views.py index 73be865d..564d319c 100644 --- a/binder/permissions/views.py +++ b/binder/permissions/views.py @@ -196,19 +196,22 @@ def get_rooms_for_user(cls, user): 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 - break + if permission_tuple[1] == 'all': + has_full_permissions = True + rooms = [] if has_required_permission: - return [{ 'auto-updates': cls._model_name() }] - else: - return [] - + 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): """