Skip to content

Commit

Permalink
Port to Channels 2
Browse files Browse the repository at this point in the history
  • Loading branch information
yakky committed May 20, 2018
1 parent e973cb0 commit b5c8d49
Show file tree
Hide file tree
Showing 21 changed files with 204 additions and 104 deletions.
12 changes: 7 additions & 5 deletions cms_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import, print_function, unicode_literals

import os

from tempfile import mkdtemp

HELPER_SETTINGS = dict(
ROOT_URLCONF='tests.example_app.urls',
INSTALLED_APPS=[
'channels',
'sekizai',
'meta',
'tests.example_app',
],
LANGUAGES=(
Expand All @@ -31,14 +31,16 @@
'hide_untranslated': False,
}
},
META_USE_SITES=True,
FILE_UPLOAD_TEMP_DIR=mkdtemp(),
META_SITE_PROTOCOL='http',
ASGI_APPLICATION='tests.example_app.routing.application',
CHANNEL_LAYERS={
'default': {
'BACKEND': 'asgi_redis.RedisChannelLayer',
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {
'hosts': [os.environ.get('REDIS_URL', 'redis://localhost:6379')],
'hosts': [('localhost', 6379)],
},
'ROUTING': 'tests.example_app.routing.channel_routing',
},
}
)
Expand Down
44 changes: 15 additions & 29 deletions knocker/consumers.py
Original file line number Diff line number Diff line change
@@ -1,37 +1,23 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import, print_function, unicode_literals

from channels import Group
from channels.sessions import channel_session
from channels.generic.websocket import JsonWebsocketConsumer


@channel_session
def ws_connect(message):
"""
Channels connection setup.
Register the current client on the related Group according to the language
"""
prefix, language = message['path'].strip('/').split('/')
gr = Group('knocker-{0}'.format(language))
gr.add(message.reply_channel)
message.channel_session['knocker'] = language
message.reply_channel.send({"accept": True})
class KnockerConsumer(JsonWebsocketConsumer):

@property
def groups(self):
"""
Attach the consumer to the selected language
"""
lang = self.scope['url_route']['kwargs'].get('language')
return 'knocker-%s' % lang,

@channel_session
def ws_receive(message):
"""
Currently no-op
"""
pass
def knocker_saved(self, event):
"""
This method handles messages sent to knocker.saved

@channel_session
def ws_disconnect(message):
"""
Channels connection close.
Deregister the client
"""
language = message.channel_session['knocker']
gr = Group('knocker-{0}'.format(language))
gr.discard(message.reply_channel)
:param event: event object
"""
self.send_json(content=event['message'])
99 changes: 83 additions & 16 deletions knocker/mixins.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import, print_function, unicode_literals

import contextlib
import json

from channels import Group
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.conf import settings
from django.db.models.signals import post_save
from django.db.models.signals import post_delete, post_save, pre_delete, pre_save
from django.utils.encoding import force_text
from django.utils.translation import get_language, ugettext_lazy as _

from .signals import notify_items # NOQA
from .signals import notify_items_post_delete # NOQA
from .signals import (
notify_items, notify_items_post_save, notify_items_pre_delete, notify_items_pre_save,
)


class KnockerModel(object):
Expand All @@ -32,20 +37,44 @@ def _connect(cls):
"""
Connect signal to current model
"""
pre_save.connect(
notify_items_pre_save, sender=cls,
dispatch_uid='knocker_pre_save_{0}'.format(cls.__name__)
)
post_save.connect(
notify_items, sender=cls,
dispatch_uid='knocker_{0}'.format(cls.__name__)
notify_items_post_save, sender=cls,
dispatch_uid='knocker_post_save_{0}'.format(cls.__name__)
)
pre_delete.connect(
notify_items_pre_delete, sender=cls,
dispatch_uid='knocker_pre_delete_{0}'.format(cls.__name__)
)
post_delete.connect(
notify_items_post_delete, sender=cls,
dispatch_uid='knocker_post_delete_{0}'.format(cls.__name__)
)

@classmethod
def _disconnect(cls):
"""
Disconnect signal from current model
"""
pre_save.disconnect(
notify_items, sender=cls,
dispatch_uid='knocker_pre_save_{0}'.format(cls.__name__)
)
post_save.disconnect(
notify_items, sender=cls,
dispatch_uid='knocker_{0}'.format(cls.__name__)
)
pre_delete.disconnect(
notify_items, sender=cls,
dispatch_uid='knocker_pre_delete_{0}'.format(cls.__name__)
)
post_delete.disconnect(
notify_items, sender=cls,
dispatch_uid='knocker_post_delete_{0}'.format(cls.__name__)
)

def get_knocker_icon(self):
"""
Expand All @@ -61,7 +90,12 @@ def get_knocker_title(self):
Defaults to 'new `model_verbose_name`'
"""
return force_text(_('new {0}'.format(self._meta.verbose_name)))
signal_type = self._get_signal_type()
titles = {
'post_save': force_text(_('new {0}'.format(self._meta.verbose_name))),
'post_delete': force_text(_('deleted {0}'.format(self._meta.verbose_name)))
}
return titles[signal_type]

def get_knocker_message(self):
"""
Expand All @@ -83,32 +117,65 @@ def get_knocker_language(self):
else:
return get_language()

def should_knock(self, created=False):
def should_knock(self, signal_type, created=False):
"""
Generic function to tell whether a knock should be emitted.
Override this to avoid emitting knocks under specific circumstances (e.g.: if the object
has just been created or update)
:param signal_type: type of signal between pre_save, post_save, pre_delete, post_delete
:param created: True if the object has been created
"""
return True
should = {
'pre_save': False,
'pre_delete': False,
'post_save': True,
'post_delete': True,
}
return should[signal_type]

@contextlib.contextmanager
def _set_signal_type(self, signal_type):
"""
Context processor that sets the signal_type on the current instance
:param signal_type: name of the catched signal
"""
self._signal_type = signal_type
yield
delattr(self, '_signal_type')

def _get_signal_type(self):
"""
Retrieve the signal type from the current instance
:return: string
"""
return getattr(self, '_signal_type', '')

def as_knock(self, created=False):
def as_knock(self, signal_type, created=False):
"""
Returns a dictionary with the knock data built from _knocker_data
"""
knock = {}
if self.should_knock(created):
for field, data in self._retrieve_data(None, self._knocker_data):
knock[field] = data
if self.should_knock(signal_type, created):
with self._set_signal_type(signal_type):
for field, data in self._retrieve_data(None, self._knocker_data):
print(field, data)
knock[field] = data
knock['action'] = created if created else signal_type.split('_')[1]
return knock

def send_knock(self, created=False):
def send_knock(self, signal_type, created=False):
"""
Send the knock in the associated channels Group
"""
knock = self.as_knock(created)
knock = self.as_knock(signal_type, created)
if knock:
gr = Group('knocker-{0}'.format(knock['language']))
gr.send({'text': json.dumps(knock)})
channel_layer = get_channel_layer()
group = 'knocker-%s' % knock['language']
async_to_sync(channel_layer.group_send)(group, {
'type': 'knocker.saved',
'message': json.dumps(knock)
})
14 changes: 8 additions & 6 deletions knocker/routing.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import, print_function, unicode_literals

from . import consumers
from channels.routing import URLRouter
from django.urls import path

channel_routing = {
'websocket.connect': consumers.ws_connect,
'websocket.receive': consumers.ws_receive,
'websocket.disconnect': consumers.ws_disconnect,
}
from .consumers import KnockerConsumer

# consumers can be freely appended: path ensure the correct match
channel_routing = URLRouter([
path('notification/<str:language>/', KnockerConsumer),
])
21 changes: 19 additions & 2 deletions knocker/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,23 @@
_thread_locals = local()


def notify_items(**kwargs):
def notify_items_pre_save(**kwargs):
return notify_items(signal_type='pre_save', **kwargs)


def notify_items_post_save(**kwargs):
return notify_items(signal_type='post_save', **kwargs)


def notify_items_pre_delete(**kwargs):
return notify_items(signal_type='pre_delete', **kwargs)


def notify_items_post_delete(**kwargs):
return notify_items(signal_type='post_delete', **kwargs)


def notify_items(signal_type, **kwargs):
"""
Signal endpoint that actually sends knocks whenever an instance is created / saved
"""
Expand All @@ -22,9 +38,10 @@ def notify_items(**kwargs):
langs = instance.get_available_languages()
else:
langs = [get_language()]
print(langs)
for lang in langs:
with override(lang):
instance.send_knock(created)
instance.send_knock(signal_type, created)
return True
except AttributeError: # pragma: no cover
pass
Expand Down
13 changes: 6 additions & 7 deletions knocker/static/js/knocker.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,11 @@ window.addEventListener('load', function () {
});
// When we're using HTTPS, use WSS too.
var ws_scheme = window.location.protocol == 'https:' ? 'wss' : 'ws';
var notifications = new ReconnectingWebSocket(ws_scheme + '://' + window.location.host + knocker_url + '/' + knocker_language);

notifications.onmessage = function (message) {
var data = JSON.parse(message.data);
addNotification(data);
}
var notifications = new channels.WebSocketBridge();
notifications.connect(ws_scheme + '://' + window.location.host + knocker_url + knocker_language + '/');
notifications.listen(function(message) {
addNotification(JSON.parse(message));
});
});


Expand All @@ -21,7 +20,7 @@ function addNotification(notification) {
// If we have permission to show browser notifications
// we can show the notification
if (window.Notification && Notification.permission === 'granted') {
data = {
var data = {
body: notification.message,
icon: notification.icon,
tag: 'notifications_' + notification.language,
Expand Down
1 change: 0 additions & 1 deletion knocker/static/js/reconnecting-websocket.min.js

This file was deleted.

1 change: 0 additions & 1 deletion requirements-docs.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
-r requirements-test.txt
Django<2.0
3 changes: 2 additions & 1 deletion requirements-test.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ flake8>=2.1.0
tox>=1.7.0
djangocms-helper
django-parler
asgi-redis
pillow
channels-redis
django-sekizai
8 changes: 3 additions & 5 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ def get_version(*file_paths):
],
include_package_data=True,
install_requires=[
'channels<2.0',
'django-meta>=1.3'
'channels>2.1',
'django-meta>=1.4'
],
test_suite='cms_helper.run',
license='BSD',
Expand All @@ -63,10 +63,8 @@ def get_version(*file_paths):
classifiers=[
'Development Status :: 3 - Alpha',
'Framework :: Django',
'Framework :: Django :: 1.8',
'Framework :: Django :: 1.9',
'Framework :: Django :: 1.10',
'Framework :: Django :: 1.11',
'Framework :: Django :: 2.0',
'Intended Audience :: Developers',
'License :: OSI Approved :: BSD License',
'Natural Language :: English',
Expand Down
1 change: 0 additions & 1 deletion tests/example_app/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,4 @@

from .models import Post


admin.site.register(Post)
7 changes: 4 additions & 3 deletions tests/example_app/migrations/0001_initial.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@
# Generated by Django 1.9.4 on 2016-04-04 19:56
from __future__ import unicode_literals

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import knocker.mixins
import meta.models
from django.conf import settings
from django.db import migrations, models

import knocker.mixins


class Migration(migrations.Migration):
Expand Down

0 comments on commit b5c8d49

Please sign in to comment.