From 62d4782dbd497d04f78e23f0f14b05d030509fe5 Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Thu, 14 Jul 2016 23:15:57 -0700 Subject: [PATCH] First version of binding code --- channels/apps.py | 4 + channels/binding/__init__.py | 1 + channels/binding/base.py | 176 +++++++++++++++++++++++++++++++++ channels/binding/websockets.py | 79 +++++++++++++++ 4 files changed, 260 insertions(+) create mode 100644 channels/binding/__init__.py create mode 100644 channels/binding/base.py create mode 100644 channels/binding/websockets.py diff --git a/channels/apps.py b/channels/apps.py index fec19cd..f2d7874 100644 --- a/channels/apps.py +++ b/channels/apps.py @@ -1,6 +1,8 @@ from django.apps import AppConfig from django.core.exceptions import ImproperlyConfigured +from .binding.base import BindingMetaclass + class ChannelsConfig(AppConfig): @@ -18,3 +20,5 @@ class ChannelsConfig(AppConfig): # Do django monkeypatches from .hacks import monkeypatch_django monkeypatch_django() + # Instantiate bindings + BindingMetaclass.register_all() diff --git a/channels/binding/__init__.py b/channels/binding/__init__.py new file mode 100644 index 0000000..5e54ac1 --- /dev/null +++ b/channels/binding/__init__.py @@ -0,0 +1 @@ +from .base import Binding diff --git a/channels/binding/base.py b/channels/binding/base.py new file mode 100644 index 0000000..39db69a --- /dev/null +++ b/channels/binding/base.py @@ -0,0 +1,176 @@ +from __future__ import unicode_literals + +import six + +from django.apps import apps +from django.db.models.signals import post_save, post_delete + +from ..channel import Group + + +class BindingMetaclass(type): + """ + Metaclass that tracks instantiations of its type. + """ + + binding_classes = [] + + def __new__(cls, name, bases, body): + klass = type.__new__(cls, name, bases, body) + if bases != (object, ): + cls.binding_classes.append(klass) + return klass + + @classmethod + def register_all(cls): + for binding_class in cls.binding_classes: + binding_class.register() + + +@six.add_metaclass(BindingMetaclass) +class Binding(object): + """ + Represents a two-way data binding from channels/groups to a Django model. + Outgoing binding sends model events to zero or more groups. + Incoming binding takes messages and maybe applies the action based on perms. + + To implement outbound, implement: + - group_names, which returns a list of group names to send to + - serialize, which returns message contents from an instance + action + + To implement inbound, implement: + - deserialize, which returns pk, data and action from message contents + - has_permission, which says if the user can do the action on an instance + - create, which takes the data and makes a model instance + - update, which takes data and a model instance and applies one to the other + + Outbound will work once you implement the functions; inbound requires you + to place one or more bindings inside a protocol-specific Demultiplexer + and tie that in as a consumer. + """ + + model = None + + @classmethod + def register(cls): + """ + Resolves models. + """ + # If model is None directly on the class, assume it's abstract. + if cls.model is None: + if "model" in cls.__dict__: + return + else: + raise ValueError("You must set the model attribute on Binding %r!" % cls) + # Optionally resolve model strings + if isinstance(cls.model, six.string_types): + cls.model = apps.get_model(cls.model) + # Connect signals + post_save.connect(cls.save_receiver, sender=cls.model) + post_delete.connect(cls.delete_receiver, sender=cls.model) + + # Outbound binding + + @classmethod + def save_receiver(cls, instance, created, **kwargs): + """ + Entry point for triggering the binding from save signals. + """ + cls.trigger_outbound(instance, "create" if created else "update") + + @classmethod + def delete_receiver(cls, instance, **kwargs): + """ + Entry point for triggering the binding from save signals. + """ + cls.trigger_outbound(instance, "delete") + + @classmethod + def trigger_outbound(cls, instance, action): + """ + Triggers the binding to possibly send to its group. + """ + self = cls() + self.instance = instance + # Check to see if we're covered + for group_name in self.group_names(instance, action): + group = Group(group_name) + group.send(self.serialize(instance, action)) + + def group_names(self, instance, action): + """ + Returns the iterable of group names to send the object to based on the + instance and action performed on it. + """ + raise NotImplementedError() + + def serialize(self, instance, action): + """ + Should return a serialized version of the instance to send over the + wire (return value must be a dict suitable for sending over a channel - + e.g., to send JSON as a WebSocket text frame, you must return + {"text": json.dumps(instance_serialized_as_dict)} + """ + raise NotImplementedError() + + # Inbound binding + + @classmethod + def trigger_inbound(cls, message): + """ + Triggers the binding to see if it will do something. + We separate out message serialization to a consumer, so this gets + native arguments. + """ + # Late import as it touches models + from django.contrib.auth.models import AnonymousUser + self = cls() + self.message = message + # Deserialize message + self.action, self.pk, self.data = self.deserialize(self.message) + self.user = getattr(self.message, "user", AnonymousUser()) + # Run incoming action + self.run_action(self.action, self.pk, self.data) + + def deserialize(self, message): + """ + Returns action, pk, data decoded from the message. pk should be None + if action is create; data should be None if action is delete. + """ + raise NotImplementedError() + + def has_permission(self, user, action, pk): + """ + Return True if the user can do action to the pk, False if not. + User may be AnonymousUser if no auth hooked up/they're not logged in. + Action is one of "create", "delete", "update". + """ + raise NotImplementedError() + + def run_action(self, action, pk, data): + """ + Performs the requested action. This version dispatches to named + functions by default for update/create, and handles delete itself. + """ + # Check to see if we're allowed + if self.has_permission(self.user, pk): + if action == "create": + self.create(data) + elif action == "update": + self.update(self.model.get(pk=pk), data) + elif action == "delete": + self.model.filter(pk=pk).delete() + else: + raise ValueError("Bad action %r" % action) + + def create(self, data): + """ + Creates a new instance of the model with the data. + """ + raise NotImplementedError() + + def update(self, instance, data): + """ + Updates the model with the data. + """ + raise NotImplementedError() diff --git a/channels/binding/websockets.py b/channels/binding/websockets.py new file mode 100644 index 0000000..d664d27 --- /dev/null +++ b/channels/binding/websockets.py @@ -0,0 +1,79 @@ +import json + +from .base import Binding +from ..generic.websockets import JsonWebsocketConsumer + + +class WebsocketBinding(Binding): + """ + Websocket-specific outgoing binding subclass that uses JSON encoding. + + To implement outbound, implement: + - group_names, which returns a list of group names to send to + - serialize_data, which returns JSON-safe data from a model instance + + To implement inbound, implement: + - has_permission, which says if the user can do the action on an instance + - create, which takes incoming data and makes a model instance + - update, which takes incoming data and a model instance and applies one to the other + """ + + # Mark as abstract + + model = None + + # Outbound + + def serialize(self, instance, action): + return { + "text": json.dumps({ + "model": "%s.%s" % ( + instance._meta.app_label.lower(), + instance._meta.object_name.lower(), + ), + "action": action, + "pk": instance.pk, + "data": self.serialize_data(instance), + }), + } + + def serialize_data(self, instance): + """ + Serializes model data into JSON-compatible types. + """ + raise NotImplementedError() + + # Inbound + + def deserialize(self, message): + content = json.loads(message['text']) + action = content['action'] + pk = content.get('pk', None) + data = content.get('data', None) + return action, pk, data + + +class WebsocketBindingDemultiplexer(JsonWebsocketConsumer): + """ + Allows you to combine multiple Bindings as one websocket consumer. + Subclass and provide a custom list of Bindings. + """ + + http_user = True + warn_if_no_match = True + bindings = None + + def receive(self, content): + # Sanity check + if self.bindings is None: + raise ValueError("Demultiplexer has no bindings!") + # Find the matching binding + model_label = content['model'] + triggered = False + for binding in self.bindings: + if binding.model_label == model_label: + binding.trigger_inbound(self.message) + triggered = True + # At least one of them should have fired. + if not triggered and self.warn_if_no_match: + raise ValueError("No binding found for model %s" % model_label)