-
-
Notifications
You must be signed in to change notification settings - Fork 793
/
base.py
289 lines (243 loc) · 9.47 KB
/
base.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
from __future__ import unicode_literals
import six
from django.apps import apps
from django.db.models.signals import post_delete, post_save, pre_delete, pre_save
from ..auth import channel_session, channel_session_user
from ..channel import Group
CREATE = 'create'
UPDATE = 'update'
DELETE = 'delete'
class BindingMetaclass(type):
"""
Metaclass that tracks instantiations of its type.
"""
register_immediately = False
binding_classes = []
def __new__(cls, name, bases, body):
klass = type.__new__(cls, name, bases, body)
if bases != (object, ):
cls.binding_classes.append(klass)
if cls.register_immediately:
klass.register()
return klass
@classmethod
def register_all(cls):
for binding_class in cls.binding_classes:
binding_class.register()
cls.register_immediately = True
@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 to serialize
model = None
# Only model fields that are listed in fields should be send by default
# if you want to really send all fields, use fields = ['__all__']
fields = None
exclude = None
# Decorators
channel_session_user = True
channel_session = False
# the kwargs the triggering signal (e.g. post_save) was emitted with
signal_kwargs = None
@classmethod
def register(cls):
"""
Resolves models.
"""
# Connect signals
for model in cls.get_registered_models():
pre_save.connect(cls.pre_save_receiver, sender=model)
post_save.connect(cls.post_save_receiver, sender=model)
pre_delete.connect(cls.pre_delete_receiver, sender=model)
post_delete.connect(cls.post_delete_receiver, sender=model)
@classmethod
def get_registered_models(cls):
"""
Resolves the class model attribute if it's a string and returns it.
"""
# 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)
# If neither fields nor exclude are not defined, raise an error
if cls.fields is None and cls.exclude is None:
raise ValueError("You must set the fields or exclude attribute on Binding %r!" % cls)
# Optionally resolve model strings
if isinstance(cls.model, six.string_types):
cls.model = apps.get_model(cls.model)
cls.model_label = "%s.%s" % (
cls.model._meta.app_label.lower(),
cls.model._meta.object_name.lower(),
)
return [cls.model]
# Outbound binding
@classmethod
def encode(cls, stream, payload):
"""
Encodes stream + payload for outbound sending.
"""
raise NotImplementedError()
@classmethod
def pre_save_receiver(cls, instance, **kwargs):
creating = instance._state.adding
cls.pre_change_receiver(instance, CREATE if creating else UPDATE)
@classmethod
def post_save_receiver(cls, instance, created, **kwargs):
cls.post_change_receiver(instance, CREATE if created else UPDATE, **kwargs)
@classmethod
def pre_delete_receiver(cls, instance, **kwargs):
cls.pre_change_receiver(instance, DELETE)
@classmethod
def post_delete_receiver(cls, instance, **kwargs):
cls.post_change_receiver(instance, DELETE, **kwargs)
@classmethod
def pre_change_receiver(cls, instance, action):
"""
Entry point for triggering the binding from save signals.
"""
if action == CREATE:
group_names = set()
else:
group_names = set(cls.group_names(instance))
if not hasattr(instance, '_binding_group_names'):
instance._binding_group_names = {}
instance._binding_group_names[cls] = group_names
@classmethod
def post_change_receiver(cls, instance, action, **kwargs):
"""
Triggers the binding to possibly send to its group.
"""
old_group_names = instance._binding_group_names[cls]
if action == DELETE:
new_group_names = set()
else:
new_group_names = set(cls.group_names(instance))
# if post delete, new_group_names should be []
self = cls()
self.instance = instance
# Django DDP had used the ordering of DELETE, UPDATE then CREATE for good reasons.
self.send_messages(instance, old_group_names - new_group_names, DELETE, **kwargs)
self.send_messages(instance, old_group_names & new_group_names, UPDATE, **kwargs)
self.send_messages(instance, new_group_names - old_group_names, CREATE, **kwargs)
def send_messages(self, instance, group_names, action, **kwargs):
"""
Serializes the instance and sends it to all provided group names.
"""
if not group_names:
return # no need to serialize, bail.
self.signal_kwargs = kwargs
payload = self.serialize(instance, action)
if payload == {}:
return # nothing to send, bail.
assert self.stream is not None
message = self.encode(self.stream, payload)
for group_name in group_names:
group = Group(group_name)
group.send(message)
@classmethod
def group_names(cls, instance):
"""
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 (e.g. {"pk": 12, "value": 42, "string": "some string"})
Kwargs are passed from the models save and delete methods.
"""
raise NotImplementedError()
# Inbound binding
@classmethod
def trigger_inbound(cls, message, **kwargs):
"""
Triggers the binding to see if it will do something.
Also acts as a consumer.
"""
# Late import as it touches models
from django.contrib.auth.models import AnonymousUser
self = cls()
self.message = message
self.kwargs = kwargs
# 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)
@classmethod
def get_handler(cls):
"""
Adds decorators to trigger_inbound.
"""
handler = cls.trigger_inbound
if cls.channel_session_user:
return channel_session_user(handler)
elif cls.channel_session:
return channel_session(handler)
else:
return handler
@classmethod
def consumer(cls, message, **kwargs):
handler = cls.get_handler()
handler(message, **kwargs)
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, action, pk):
if action == "create":
self.create(data)
elif action == "update":
self.update(pk, data)
elif action == "delete":
self.delete(pk)
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, pk, data):
"""
Updates the model with the data.
"""
raise NotImplementedError()
def delete(self, pk):
"""
Deletes the model instance.
"""
self.model.objects.filter(pk=pk).delete()