# HG changeset patch # User Thierry Florac # Date 1464875572 -7200 # Node ID f53281280c23894545a8e255a0dd249292d9cbb2 First commit diff -r 000000000000 -r f53281280c23 src/pyams_notify/__init__.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/pyams_notify/__init__.py Thu Jun 02 15:52:52 2016 +0200 @@ -0,0 +1,24 @@ +# +# Copyright (c) 2008-2015 Thierry Florac +# All Rights Reserved. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE. +# + +__docformat__ = 'restructuredtext' + + +from pyramid.i18n import TranslationStringFactory +_ = TranslationStringFactory('pyams_notify') + + +def includeme(config): + """Pyramid include""" + + from .include import include_package + include_package(config) diff -r 000000000000 -r f53281280c23 src/pyams_notify/doctests/README.txt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/pyams_notify/doctests/README.txt Thu Jun 02 15:52:52 2016 +0200 @@ -0,0 +1,3 @@ +==================== +pyams_notify package +==================== diff -r 000000000000 -r f53281280c23 src/pyams_notify/event.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/pyams_notify/event.py Thu Jun 02 15:52:52 2016 +0200 @@ -0,0 +1,120 @@ +# +# Copyright (c) 2008-2015 Thierry Florac +# All Rights Reserved. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE. +# + +__docformat__ = 'restructuredtext' + + +# import standard library +import json +from datetime import datetime + +# import interfaces +from pyams_notify.interfaces import INotification, INotificationHandler +from pyams_security.interfaces import IPrincipalInfo +from pyams_security.interfaces.profile import IPublicProfile +from pyams_skin.interfaces.configuration import IBackOfficeConfiguration +from transaction.interfaces import ITransactionManager + +# import packages +from pyams_security.utility import get_principal +from pyams_utils.date import format_datetime +from pyams_utils.request import query_request +from pyams_utils.url import absolute_url +from ws4py.client.threadedclient import WebSocketClient +from zope.interface import implementer +from zope.schema.fieldproperty import FieldProperty + + +@implementer(INotification) +class Notification(object): + """Notification object""" + + action = FieldProperty(INotification['action']) + category = FieldProperty(INotification['category']) + title = FieldProperty(INotification['title']) + message = FieldProperty(INotification['message']) + source = FieldProperty(INotification['source']) + target = FieldProperty(INotification['target']) + url = FieldProperty(INotification['url']) + timestamp = FieldProperty(INotification['timestamp']) + user_data = FieldProperty(INotification['user_data']) + + def __init__(self, request, **settings): + self.request = request + self.context = settings.pop('context', request.context) + self.action = settings.pop('action', None) + self.category = settings.pop('category', None) + self.message = settings.pop('message', None) + source = settings.pop('source', request.principal.id) + if IPrincipalInfo.providedBy(source): + source = source.id + self.source_id = source + self.url = settings.pop('url', None) + self.timestamp = format_datetime(datetime.utcnow()) + self.user_data = settings + + def get_source(self, principal_id=None): + if principal_id is None: + principal_id = self.request.principal.id + principal = get_principal(self.request, principal_id) + self.source = {'id': principal.id, + 'title': principal.title, + 'avatar': absolute_url(IPublicProfile(principal).avatar, + self.request, + '++thumb++square:32x32.png')} + configuration = IBackOfficeConfiguration(self.request.root) + self.title = '{0} - {1}'.format(configuration.short_title, principal.title) + + def get_target(self): + handler = self.request.registry.queryAdapter(self, INotificationHandler, name=self.category) + if handler is not None: + self.target = handler.get_target() + + def send(self): + self.get_source(self.source_id) + self.get_target() + ITransactionManager(self.request.context).get().addAfterCommitHook(notify, kws={'event': self, + 'request': self.request}) + + +class NotificationEncoder(json.JSONEncoder): + """Notification encoder""" + + def default(self, obj): + if isinstance(obj, Notification): + return {'action': obj.action, + 'category': obj.category, + 'title': obj.title, + 'message': obj.message, + 'source': obj.source, + 'target': obj.target, + 'url': obj.url, + 'timestamp': obj.timestamp} + else: + return super(NotificationEncoder, self).default(obj) + + +def notify(status, event, request=None): + """Send event notification""" + if not status: # aborted transaction + return + if request is None: + request = query_request() + if request is None: + return + ws = WebSocketClient('ws://{0}/notify'.format(request.registry.settings.get('pyams_notify.tcp_handler'))) + ws.connect() + try: + json_data = json.dumps(event, cls=NotificationEncoder) + ws.send(json_data) + finally: + ws.close() diff -r 000000000000 -r f53281280c23 src/pyams_notify/handlers/__init__.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/pyams_notify/handlers/__init__.py Thu Jun 02 15:52:52 2016 +0200 @@ -0,0 +1,20 @@ +# +# Copyright (c) 2008-2015 Thierry Florac +# All Rights Reserved. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE. +# + +__docformat__ = 'restructuredtext' + + +# import standard library + +# import interfaces + +# import packages diff -r 000000000000 -r f53281280c23 src/pyams_notify/handlers/login.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/pyams_notify/handlers/login.py Thu Jun 02 15:52:52 2016 +0200 @@ -0,0 +1,62 @@ +# +# Copyright (c) 2008-2015 Thierry Florac +# All Rights Reserved. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE. +# + +__docformat__ = 'restructuredtext' + + +# import standard library + +# import interfaces +from pyams_notify.interfaces import INotification, INotificationHandler +from pyams_security.interfaces import IAuthenticatedPrincipalEvent, IProtectedObject + +# import packages +from pyams_notify.event import Notification +from pyams_security.utility import get_principal +from pyams_utils.adapter import adapter_config, ContextAdapter +from pyams_utils.request import query_request +from pyramid.events import subscriber + +from pyams_notify import _ + + +# +# User authentication notification +# + +@subscriber(IAuthenticatedPrincipalEvent) +def handle_authenticated_principal(event): + """Handle authenticated principal""" + request = query_request() + if request is None: + return + translate = request.localizer.translate + principal = get_principal(request, event.principal_id) + notification = Notification(request=request, + action='notify', + category='user.login', + source=event.principal_id, + message=translate(_("User {0} logged in...")).format(principal.title)) + notification.send() + + +@adapter_config(name='user.login', context=INotification, provides=INotificationHandler) +class UserLoginNotificationHandler(ContextAdapter): + """User login notification handler""" + + def get_target(self): + root = self.context.request.root + protection = IProtectedObject(root) + principals = {'system:admin'} | \ + protection.get_principals('system.Manager') | \ + protection.get_principals('pyams.Webmaster') + return {'principals': tuple(principals)} diff -r 000000000000 -r f53281280c23 src/pyams_notify/handlers/workflow.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/pyams_notify/handlers/workflow.py Thu Jun 02 15:52:52 2016 +0200 @@ -0,0 +1,69 @@ +# +# Copyright (c) 2008-2015 Thierry Florac +# All Rights Reserved. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE. +# + +__docformat__ = 'restructuredtext' + + +# import standard library + +# import interfaces +from pyams_i18n.interfaces import II18n +from pyams_notify.interfaces import INotification, INotificationHandler +from pyams_security.interfaces import IProtectedObject +from pyams_workflow.interfaces import IWorkflowTransitionEvent + +# import packages +from pyams_notify.event import Notification +from pyams_utils.adapter import adapter_config, ContextAdapter +from pyams_utils.request import query_request +from pyams_utils.url import absolute_url +from pyramid.events import subscriber +from pyramid.location import lineage + + +@subscriber(IWorkflowTransitionEvent) +def handle_workflow_event(event): + """Handle workflow transition event""" + request = query_request() + if request is None: + return + transition = event.transition + if transition.user_data.get('notify') is None: + return + translate = request.localizer.translate + notification = Notification(request=request, + action='notify', + category='content.workflow', + message=translate(transition.user_data['notify_message']).format( + II18n(event.object).query_attribute('title', request=request)), + url=absolute_url(event.object, request, 'admin.html'), + transition=transition) + notification.send() + + +@adapter_config(name='content.workflow', context=INotification, provides=INotificationHandler) +class ContentWorkflowTransitionNotificationHandler(ContextAdapter): + """Content workflow transition notification handler""" + + def get_target(self): + notified_roles = self.context.user_data['transition'].user_data['notify'] + if '*' in notified_roles: + return {} + principals = set() + for context in lineage(self.context.context): + for role_id in notified_roles: + protection = IProtectedObject(context, None) + if protection is not None: + principals |= protection.get_principals(role_id) + if self.context.source['id'] in principals: + principals.remove(self.context.source['id']) + return {'principals': tuple(principals)} diff -r 000000000000 -r f53281280c23 src/pyams_notify/include.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/pyams_notify/include.py Thu Jun 02 15:52:52 2016 +0200 @@ -0,0 +1,30 @@ +# +# Copyright (c) 2008-2015 Thierry Florac +# All Rights Reserved. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE. +# + +__docformat__ = 'restructuredtext' + + +# import standard library + +# import interfaces + +# import packages + + +def include_package(config): + """Pyramid include""" + + # add translations + config.add_translation_dirs('pyams_notify:locales') + + # load registry components + config.scan() diff -r 000000000000 -r f53281280c23 src/pyams_notify/interfaces/__init__.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/pyams_notify/interfaces/__init__.py Thu Jun 02 15:52:52 2016 +0200 @@ -0,0 +1,70 @@ +# +# Copyright (c) 2008-2015 Thierry Florac +# All Rights Reserved. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE. +# + +__docformat__ = 'restructuredtext' + + +# import standard library + +# import interfaces +from zope.interface import Interface + +# import packages +from zope.schema import TextLine, Text, Dict, Datetime + +from pyams_notify import _ + + +MEMCACHED_QUEUE_KEY = b'_PyAMS_notify_messages_queue_' + + +class INotification(Interface): + """Notification interface""" + + action = TextLine(title=_("Notification action")) + + category = TextLine(title=_("Notification category")) + + title = TextLine(title=_("Notification title")) + + message = Text(title=_("Notification message")) + + source = Dict(title=_("Notification source"), + description=_("Attributes of the principal which emitted the notification"), + key_type=TextLine(), + value_type=TextLine()) + + target = Dict(title=_("Notification target"), + description=_("Notification targets (principals, roles...)"), + key_type=TextLine()) + + url = TextLine(title=_("Notification URL"), + description=_("URL targetted by this notification"), + required=False) + + timestamp = TextLine(title=_("Notification timestamp")) + + user_data = Dict(title=_("User data")) + + def send(self): + """Send notification to recipients""" + + +class INotificationHandler(Interface): + """Notification handler interface""" + + def get_target(self): + """Get notification target""" + + +class IUserProfileAnnotations(Interface): + """User profile annotations subscriptions""" diff -r 000000000000 -r f53281280c23 src/pyams_notify/locales/fr/LC_MESSAGES/pyams_notify.mo Binary file src/pyams_notify/locales/fr/LC_MESSAGES/pyams_notify.mo has changed diff -r 000000000000 -r f53281280c23 src/pyams_notify/locales/fr/LC_MESSAGES/pyams_notify.po --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/pyams_notify/locales/fr/LC_MESSAGES/pyams_notify.po Thu Jun 02 15:52:52 2016 +0200 @@ -0,0 +1,78 @@ +# +# French translations for PACKAGE package +# This file is distributed under the same license as the PACKAGE package. +# Thierry Florac , 2016. +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE 1.0\n" +"POT-Creation-Date: 2016-06-01 15:50+0200\n" +"PO-Revision-Date: 2016-06-01 15:51+0200\n" +"Last-Translator: Thierry Florac \n" +"Language-Team: French\n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Lingua 3.10.dev0\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#: src/pyams_notify/handlers/login.py:49 +#, python-format +msgid "User {0} logged in..." +msgstr "L'utilisateur {0} s'est connecté" + +#: src/pyams_notify/viewlet/__init__.py:36 +msgid "Notifications" +msgstr "Notifications" + +#: src/pyams_notify/viewlet/templates/notifications.pt:3 +msgid "No notification to display." +msgstr "Aucune notification à afficher." + +#: src/pyams_notify/interfaces/__init__.py:33 +msgid "Notification action" +msgstr "Action de la notification" + +#: src/pyams_notify/interfaces/__init__.py:35 +msgid "Notification category" +msgstr "Catgéorie" + +#: src/pyams_notify/interfaces/__init__.py:37 +msgid "Notification title" +msgstr "Titre" + +#: src/pyams_notify/interfaces/__init__.py:39 +msgid "Notification message" +msgstr "Message" + +#: src/pyams_notify/interfaces/__init__.py:41 +msgid "Notification source" +msgstr "Source" + +#: src/pyams_notify/interfaces/__init__.py:42 +msgid "Attributes of the principal which emitted the notification" +msgstr "Propriétés de l'utilisateur à la source de la notification" + +#: src/pyams_notify/interfaces/__init__.py:46 +msgid "Notification target" +msgstr "Cible" + +#: src/pyams_notify/interfaces/__init__.py:47 +msgid "Notification targets (principals, roles...)" +msgstr "Cibles de la notification (utilisateurs, roles...)" + +#: src/pyams_notify/interfaces/__init__.py:50 +msgid "Notification URL" +msgstr "URL" + +#: src/pyams_notify/interfaces/__init__.py:51 +msgid "URL targetted by this notification" +msgstr "URL visée par cette notification" + +#: src/pyams_notify/interfaces/__init__.py:54 +msgid "Notification timestamp" +msgstr "Horodatage" + +#: src/pyams_notify/interfaces/__init__.py:56 +msgid "User data" +msgstr "Données utilisateur" diff -r 000000000000 -r f53281280c23 src/pyams_notify/locales/pyams_notify.pot --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/pyams_notify/locales/pyams_notify.pot Thu Jun 02 15:52:52 2016 +0200 @@ -0,0 +1,78 @@ +# +# SOME DESCRIPTIVE TITLE +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , 2016. +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE 1.0\n" +"POT-Creation-Date: 2016-06-01 15:50+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Lingua 3.10.dev0\n" + +#: ./src/pyams_notify/handlers/login.py:49 +#, python-format +msgid "User {0} logged in..." +msgstr "" + +#: ./src/pyams_notify/viewlet/__init__.py:36 +msgid "Notifications" +msgstr "" + +#: ./src/pyams_notify/viewlet/templates/notifications.pt:3 +msgid "No notification to display." +msgstr "" + +#: ./src/pyams_notify/interfaces/__init__.py:33 +msgid "Notification action" +msgstr "" + +#: ./src/pyams_notify/interfaces/__init__.py:35 +msgid "Notification category" +msgstr "" + +#: ./src/pyams_notify/interfaces/__init__.py:37 +msgid "Notification title" +msgstr "" + +#: ./src/pyams_notify/interfaces/__init__.py:39 +msgid "Notification message" +msgstr "" + +#: ./src/pyams_notify/interfaces/__init__.py:41 +msgid "Notification source" +msgstr "" + +#: ./src/pyams_notify/interfaces/__init__.py:42 +msgid "Attributes of the principal which emitted the notification" +msgstr "" + +#: ./src/pyams_notify/interfaces/__init__.py:46 +msgid "Notification target" +msgstr "" + +#: ./src/pyams_notify/interfaces/__init__.py:47 +msgid "Notification targets (principals, roles...)" +msgstr "" + +#: ./src/pyams_notify/interfaces/__init__.py:50 +msgid "Notification URL" +msgstr "" + +#: ./src/pyams_notify/interfaces/__init__.py:51 +msgid "URL targetted by this notification" +msgstr "" + +#: ./src/pyams_notify/interfaces/__init__.py:54 +msgid "Notification timestamp" +msgstr "" + +#: ./src/pyams_notify/interfaces/__init__.py:56 +msgid "User data" +msgstr "" diff -r 000000000000 -r f53281280c23 src/pyams_notify/resources.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/pyams_notify/resources.py Thu Jun 02 15:52:52 2016 +0200 @@ -0,0 +1,33 @@ +# +# Copyright (c) 2008-2015 Thierry Florac +# All Rights Reserved. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE. +# + +__docformat__ = 'restructuredtext' + + +# import standard library + +# import interfaces +from pyams_skin.interfaces.resources import IResources +from pyramid.interfaces import IRequest + +# import packages +from pyams_notify.skin import pyams_notify +from pyams_utils.adapter import adapter_config, ContextRequestViewAdapter +from zope.interface import Interface + + +@adapter_config(context=(Interface, IRequest, Interface), provides=IResources) +class ResourcesAdapter(ContextRequestViewAdapter): + """Get context resources""" + + def get_resources(self): + pyams_notify.need() diff -r 000000000000 -r f53281280c23 src/pyams_notify/skin/__init__.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/pyams_notify/skin/__init__.py Thu Jun 02 15:52:52 2016 +0200 @@ -0,0 +1,35 @@ +# +# Copyright (c) 2008-2015 Thierry Florac +# All Rights Reserved. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE. +# + +__docformat__ = 'restructuredtext' + + +# import standard library + +# import interfaces + +# import packages +from fanstatic import Library, Resource +from pyams_skin import myams, jquery + + +library = Library('pyams_notify', 'resources') + +websocket = Resource(library, 'js/jquery-WebSocket.js', + minified='js/jquery-WebSocket.min.js', + depends=[jquery], + bottom=True) + +pyams_notify = Resource(library, 'js/pyams_notify.js', + minified='js/pyams_notify.min.js', + depends=[myams, websocket], + bottom=True) diff -r 000000000000 -r f53281280c23 src/pyams_notify/skin/resources/js/jquery-WebSocket.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/pyams_notify/skin/resources/js/jquery-WebSocket.js Thu Jun 02 15:52:52 2016 +0200 @@ -0,0 +1,754 @@ +/* vim: set expandtab tabstop=4 shiftwidth=4 softtabstop=4: */ + +/** + * jquery.WebSocket + * + * jquery-WebSocket.js - enables WebSocket support with emulated fallback + * + * One simple interface $.WebSocket(url, protocol, options); thats it. + * The same interface as current native WebSocket implementation. The same + * native events (onopen, onmessage, onerror, onclose) + custom event onsend. + * + * But jquery.WebSockets adds some nice features: + * + * [x] Multiplexing - Use a single socket connection and as many logical pipes + * within as your browser supports. All these pipes are emulated WebSockets + * also with the same API + same events! Use each pipe as WebSocket! But + * this requires you to implement the protocol on this level of communication + * The data is en- + decoded in a special way to make multiplexing possible + * + * [x] Interface for adding protocol to manipulate data before they are send + * and right after they arrive before event onmessage is fired! + * + * LICENSE: + * jquery.WebSocket + * + * Copyright (c) 2012, Benjamin Carl - All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - All advertising materials mentioning features or use of this software + * must display the following acknowledgement: This product includes software + * developed by Benjamin Carl and other contributors. + * - Neither the name Benjamin Carl nor the names of other contributors + * may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * + * Please feel free to contact us via e-mail: opensource@clickalicious.de + * + * @category jquery + * @package jquery_plugin + * @subpackage jquery_plugin_WebSocket + * @author Benjamin Carl + * @copyright 2012 - 2013 Benjamin Carl + * @license http://www.opensource.org/licenses/bsd-license.php The BSD License + * @version 0.0.3 + * @link http://www.clickalicious.de + * @see - + * @since File available since Release 1.0.0 + */ +(function($){ + + // attach to jQuery + $.extend({ + + // export WebSocket = $.WebSocket + WebSocket: function(url, protocol, options) + { + /** + * The default values, use comma to separate the settings, + * example: + * + * @type {jquery.WebSocket} + * @private + */ + var defaults = { + url: url, + http: null, + enableProtocols: false, + enablePipes: false, + encoding: 'utf-8', // fallback: encoding for AJAX LP + method: 'post', // fallback: method for AJAX LP + delay: 0, // number of ms to delay open event + interval: 3000 // number of ms between poll request + }; + + // overwrite (append) to option defaults + options = $.extend( + {}, + defaults, + options + ); + + // WebSocket Id and readyStates + const WS_ID = 'WebSocketPipe'; + const CONNECTING = 0; + const OPEN = 1; + const CLOSING = 2; + const CLOSED = 3; + + // the function table to store references to callbacks + var _functionTable = { + onopen: function() {}, + onerror: function(e) {}, + onclose: function() {}, + onmessage: function(e) {}, + send: function(d) { _ws._send(d); } + }; + + /*********************************************************************************************************** + * + * PRIVATE MEMBERS + * + **********************************************************************************************************/ + + /** + * private: _token + * + * Returns a random token on request + * + * This method is intend to return a random token on + * request e.g. used as pipe-Id + * + * @returns {String} Random token + */ + function _token() + { + return Math.random().toString(36).substr(2); + }; + + /** + * private: _urlWsToHttp + * + * Converts a ws:// or wss:// style link to a http:// or https:// link + * + * This method is intend to convert ws-links to http-links + * + * @returns {String} http-link + */ + function _urlWsToHttp(url) + { + var protocol = (url.attr('protocol') === 'wss') ? 'https://' : 'http://'; + var host = url.attr('host'); + var port = ( + (url.attr('protocol') == 'wss' && url.attr('port') != 443) || + (url.attr('protocol') == 'ws' && url.attr('port') != 80) ? + ':' + url.attr('port') : '' + ); + var path = ((url.attr('path') != '/') ? url.attr('path') : ''); + + // return new combined url + return protocol + host + port + path; + }; + + /** + * private: _dispatchProtocol + * + * Dispatch event to registered protocol handler + * (events: onmessage, onsend[only on emulated WebSocket!]) + * + * @param e The event object + * + * @returns The processed object + */ + function _dispatchProtocol(e, direction, ws) + { + // give object event from protocol to protocol + for (var protocol in ws.protocols) { + e = ws.protocols[protocol].callback(e, direction); + } + + // return dirty object + return e; + }; + + /** + * private: _dispatchPipe + * + * Dispatch event to pipe + * (events: onmessage, onsend) + */ + function _dispatchPipe(id, e) + { + for (var pipe in ws.pipes) { + if (pipe == id) { + var p = ws.pipes[pipe]; + if (p.onmessage !== undefined && + typeof(p.onmessage) === 'function' + ) { + p.onmessage(e); + } + } + } + }; + + /** + * private: _proxy + * + * This is the proxy between an intercepted call and calls + * the user defined callback + * + * @param {String} trigger The trigger (event-name) to dispatch + * @param {Object} e The event object to dispatch + * + * @returns The result of dispatch (depends on operation!) + * @private + */ + function _proxy(trigger, e) + { + return _functionTable[trigger](e); + }; + + /** + * private: _injectHook + * + * Injects hooks into a given WebSocket object + * + * This method injects the hooks for the given name of an event (event) + * into a given WebSocket object (ws). + * + * @param {String} event The name of the event to hook + * @param {Object} ws An instance of a WebSocket to patch (inject to) + * + * @returns void + * @private + */ + function _injectHook(event, ws) + { + // + if (event == 'send') { + ws._send = ws.send; + } + + // install our proxy method for intercepting events + ws[event] = function(e) { + var uid; + + if (event == 'onmessage') { + // apply registered protocols first + e = _dispatchProtocol(e, 'i', ws); + + // where to get info -> multiplexed? + // if multiplexed look into package for uid + if (ws.multiplexed) { + // extract uid + //uid = /"uid"\:\s"([a-z0-9A-z]*)"/.exec(e.data)[1]; + uid = JSON.parse(e.data).uid; + } + + if (uid !== undefined) { + $(ws.pipes[uid]).trigger(e); + } else { + // dispatch to all pipes + for (pipe in ws.pipes) { + $(ws.pipes[pipe]).trigger( + e + ); + } + } + } + + (event == 'send') ? e = _dispatchProtocol(e, 'o', ws) : null; + + _proxy(event, e); + }; + + // override setter with custom hook to fetch user defined callbacks + window.WebSocket.prototype.__defineSetter__( + event, + function(v) { + _functionTable[event] = v; + } + ); + + // override getter with custom hook to fetch user defined callbacks + window.WebSocket.prototype.__defineGetter__( + event, + function() { + return _functionTable[event]; + } + ); + }; + + /** + * Returns a fresh pipe-object, which is in fact an emulated WebSocket + * which is extended here within on the fly with our pipe logic. + * + * @param {String} url The url connect to (resource or path ...) + * @param {String} id The optional Id of the pipe (unique-identifier) + * + * @returns {Object} A fresh pipe (emulated WebSocket) + * @private + */ + function _protocol(name, callback) + { + return { + name: name, + callback: callback + }; + }; + + /** + * Returns a fresh pipe-object, which is in fact an emulated WebSocket + * which is extended here within on the fly with our pipe logic. + * + * @param {String} url The url connect to (resource or path ...) + * @param {String} id The optional Id of the pipe (unique-identifier) + * + * @returns {Object} A fresh pipe (emulated WebSocket) + * @private + */ + function _pipe(url) + { + // create, merge and return new pipe instance + return $.extend( + _getWebSocketSkeleton(url, false), // merge an websocket structure _getWebSocketSkeleton(url) + { // with our additions {} + packet: function(uid) { + return { + type: WS_ID, + action: 'message', + data: null, + uid: uid + }; + }, + + startup: function() { + var p = this.packet(this.uid, this.url); + p.action = 'startup'; + p.data = this.url; + + _ws.send( + JSON.stringify(p) + ); + + }, + + shutdown: function() { + // send shutdown pipe packet + var p = this.packet(this.uid, this.url); + p.action = 'shutdown'; + + // send as text string + _ws.send( + JSON.stringify(p) + ); + }, + + send: function(data) { + // get packet + var p = this.packet(this.uid, this.url); + p.data = data; + + // send as text string + _ws.send( + JSON.stringify(p) + ); + + // trigger event send + $(this).triggerHandler('send'); + }, + + start: function() { + // send initial packet to server for handshaking + this.startup(); + + // trigger event open + $(this).triggerHandler('open'); + + return this; + }, + + close: function() { + // send packet close + this.shutdown(); + + // trigger native event WebSocket:onclose (native) + $(this).triggerHandler('close'); + } + } + ); + } + + /** + * Returns skeleton of a WebSocket object + * + * @param {String} url The url connect to (resource or path ...) + * @param {String} id The optional Id of the pipe (unique-identifier) + * + * @returns {Object} A fresh pipe (emulated WebSocket) + * @private + */ + function _getWebSocketSkeleton(url, isNative) + { + return { + type: 'WebSocket', // CUSTOM type String type of object + uid: _token(), // CUSTOM uid an unique Id used to identify the ws + //native: isNative, // CUSTOM native status as boolean + readyState: CONNECTING, // NATIVE default status is 0 = CONNECTING + bufferedAmount: 0, // NATIVE integer currently buffered data in bytes + url: url, // NATIVE url The url to connect to + + send: function(data) {}, // NATIVE send() sending "data" to server + start: function() {}, // CUSTOM start() used as custom trigger for opening + close: function() {}, // NATIVE close() closes the connection + + onopen: function() {}, // NATIVE EVENT + onerror: function(e) {}, // NATIVE EVENT + onclose: function() {}, // NATIVE EVENT + onmessage: function(e) {}, // NATIVE EVENT + + protocols: {}, // CUSTOM container for protocols in user defined order + pipes: {}, // CUSTOM container for all registered pipes + multiplexed: false, // CUSTOM multiplexed status of this object + + /** + * Export: registerPipe() + * + * Creates a new pipe and return pipe-reference. + * + * This method creates a "pipe" which is in fact an emulated WebSocket + * (the same emulation as used in older browsers). These "pipes" are + * used as a logical connection within a physical connection to the Server + * + * Pipe 1 -> O==\ (WebSocket) /==O -> Pipe 1 + * Pipe 2 -> O==========================O -> Pipe 2 + * Pipe 3 -> O==/ \==O -> Pipe 3 + * + * So we can use those Pipes in the same way like the original WebSocket object. + * You can use the same events what enables you and your app to use less + * connections between server and client and use different endpoints for your + * services too. + * + * @param {String} url The url to connect to + * @param {String} protocol The protocol to use + * @param {Object} options Custom options to pass through + * + * @returns {Object} the instance created + */ + registerPipe: function(url, protocol, options) { + var p = new _pipe(url); + this.multiplexed = true; + + // we iterate the functionTable and use the events for injecting our hooks + for (event in _functionTable) { + // inject all hooks except "send" + if (event != 'send') { + _injectHook(event, p); + } + } + + // try: + // return ws.pipes[p.id] = p = new _pipe(url, id); + var r = this.pipes[p.uid] = $.extend(p, options).start(); + return r; + }, + unregisterPipe: function(id) { + this.pipes[id] = null; + (this.pipes.length === 0) ? this.multiplexed = false : void(0); + // timer? + }, + registerProtocol: function(name, callback) { + var p = new _protocol(name, callback); + return this.protocols[name] = p; + }, + unregisterProtocol: function(name) { + this.protocols[name] = null; + }, + extension: null, + protocol: null, + reason: null, + binaryType: null + }; + } + + /** + * Creates and return a new jQuery error event with passed var added as .data + * + * @param {Mixed} data The data to add to event + * + * @returns {jQuery.Event} An jQuery error event object + * @private + */ + function _ErrorEvent(data) + { + // create MessageEvent event and add received data + var e = jQuery.Event('error'); + e.data = data; + return e; + } + + /** + * Creates and return a new jQuery message event with passed var added as .data + * + * @param {Mixed} data The data to add to event + * + * @returns {jQuery.Event} An jQuery message event object + * @private + */ + function _MessageEvent(data) + { + // create MessageEvent event and add received data + var e = jQuery.Event('message'); + e.data = data; + return e; + } + + /** + * Returns an emulated WebSocket which is build upon jQueries AJAX functionality. + * + * @param {Mixed} data The data to add to event + * + * we create and return an emulated WebSocket Object. We you can use this object in a very + * similar way to the native WebSocket Implementation. The connection was graceful degraded to + * AJAX long polling ... + * + * @returns ??? + * @private + */ + function _WebSocket(url) + { + var _interval, _handler, _emulation = { + + /** + * export: send + * + * Sends data via options.method to server (http/xhr request) + * + * This method is intend to ... + * + * @param mixed data The data to send + * + * @returns boolean TRUE on success, otherwise FALSE + */ + send: function(data) { + // default result is true = success + var success = true; + + // send data via jQuery ajax() + $.ajax({ + async: false, + type: options.method, + url: url + ( + (options.method == 'GET' && options.arguments) ? + '?' + $.param(options.arguments) : + '' + ), + data: ( + (options.method == 'POST' && options.arguments) ? + $.param($.extend(options.arguments, {data: data})) : + null + ), + dataType: 'text', + contentType: "application/x-www-form-urlencoded; charset=" + options.encoding, + success: function(data) { + // trigger native event MessageEvent (emulated) + $(_emulation).trigger( + new _MessageEvent(_dispatchProtocol(data, 'i', _emulation)) + ); + }, + error: function(xhr, data, errorThrown) { + // in case of error no success + success = false; + + // trigger native event ErrorEvent (emulated) + $(_emulation).trigger( + _ErrorEvent(data) + ); + } + }); + + // return result of operation + return success; + }, + + /** + * export: close + * + * Closes an existing and open connection + * + * This method is intend to ... + * + * @returns void + */ + close: function() { + // kill timer! + clearTimeout(_handler); + clearInterval(_interval); + + // update readyState + this.readyState = CLOSED; + + // trigger native event WebSocket:onclose (native) + $(_emulation).triggerHandler('close'); + } + }; + + /** + * private: _poll + * + * Polls server for new data and returns the result + * + * This method is intend to ... + * + * @returns void + * @private + */ + function _poll() { + $.ajax({ + type: options.method, + url: url + ( + (options.method == 'GET' && options.arguments) ? + '?' + $.param(options.arguments) : + '' + ), + dataType: 'text', + data: ( + (options.method == 'POST' && options.arguments) ? + $.param(options.arguments) : + null + ), + success: function(data) { + // trigger our emulated MessageEvent + $(_emulation).trigger( + new _MessageEvent(data) + ); + }, + error: function(xhr, data, errorThrown) { + // in case of error no success + success = false; + + // trigger native event ErrorEvent (emulated) + $(_emulation).trigger( + _ErrorEvent(data) + ); + } + }); + + // trigger custom event WebSocket:onsend (custom) + $(_emulation).triggerHandler('send'); + }; + + // run our emulation + _handler = setTimeout( + function() { + _emulation.readyState = OPEN; + _poll(); + _interval = setInterval(_poll, options.interval); + + // trigger event WebSocket:onopen (emulated) + $(_emulation).triggerHandler('open'); + }, + options.delay + ); + + // return emulated socket implementation + return _emulation; + }; + + /** + * private: _extend + * + * Copies all properties from source to destination object as + * long as they not exist in destination. + * + * This method is intend to ... + * + * @param {Object} source The object to copy from + * @param {Object} destination The object to copy to + * + * @returns {Object} The object destination with new properties set + * @private + */ + function _extend(source, destination) { + for (property in source) { + if (!destination[property]) { + destination[property] = source[property]; + } + } + + return destination; + } + + /** + * private: _getWebSocket + * + * Returns a WebSocket-Object (native or emulated) + * + * This method is intend to return a WebSocket-Object no matter + * if the browser supports native WebSockets or not. For older + * Browsers the WebSocket-Object is emulated with Ajax (Fake) + * Push: Long Polling. + * + * @param {String} url The resource-locator (e.g. ws://127.0.0.1:80/ || + * ws://127.0.0.1:80/this/is/fallback/) + * + * @returns {Object} WebSocket + * @private + */ + function _getWebSocket(url) + { + var ws, isNative = true; + + // websocket support built in? + if (window.WebSocket) { + // in firefox we use MozWebSocket (but it seems that from now (FF 17) MozWebsocket is removed) + if (typeof(MozWebSocket) == 'function') { + ws = new MozWebSocket(url); + } else { + ws = new WebSocket(url); + } + + } else { + // inject emulated WebSocket implementation into DOM + window.WebSocket = _WebSocket; + + url = options.http; + isNative = false; + ws = new WebSocket(url); + } + + // extend it with our additions and return + return _extend( + _getWebSocketSkeleton(url, isNative), + ws + ); + }; + + /*********************************************************************************************************** + * + * MAIN() + * + **********************************************************************************************************/ + + // get WebSocket object (either native or emulated) + var _ws = _getWebSocket(options.url); + + // we iterate the functionTable and use the events for injecting our hooks + for (event in _functionTable) { + _injectHook(event, _ws); + } + + // return WebSocket + return _ws; + } + }); +})(jQuery); diff -r 000000000000 -r f53281280c23 src/pyams_notify/skin/resources/js/jquery-WebSocket.min.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/pyams_notify/skin/resources/js/jquery-WebSocket.min.js Thu Jun 02 15:52:52 2016 +0200 @@ -0,0 +1,1 @@ +(function(a){a.extend({WebSocket:function(h,q,d){var o={url:h,http:null,enableProtocols:false,enablePipes:false,encoding:"utf-8",method:"post",delay:0,interval:3000};d=a.extend({},o,d);const z="WebSocketPipe";const m=0;const w=1;const x=2;const c=3;var s={onopen:function(){},onerror:function(A){},onclose:function(){},onmessage:function(A){},send:function(A){t._send(A)}};function y(){return Math.random().toString(36).substr(2)}function r(B){var E=(B.attr("protocol")==="wss")?"https://":"http://";var C=B.attr("host");var A=((B.attr("protocol")=="wss"&&B.attr("port")!=443)||(B.attr("protocol")=="ws"&&B.attr("port")!=80)?":"+B.attr("port"):"");var D=((B.attr("path")!="/")?B.attr("path"):"");return E+C+A+D}function g(C,B,A){for(var D in A.protocols){C=A.protocols[D].callback(C,B)}return C}function l(D,C){for(var A in ws.pipes){if(A==D){var B=ws.pipes[A];if(B.onmessage!==undefined&&typeof(B.onmessage)==="function"){B.onmessage(C)}}}}function j(A,B){return s[A](B)}function n(B,A){if(B=="send"){A._send=A.send}A[B]=function(D){var C;if(B=="onmessage"){D=g(D,"i",A);if(A.multiplexed){C=JSON.parse(D.data).uid}if(C!==undefined){a(A.pipes[C]).trigger(D)}else{for(pipe in A.pipes){a(A.pipes[pipe]).trigger(D)}}}(B=="send")?D=g(D,"o",A):null;j(B,D)};window.WebSocket.prototype.__defineSetter__(B,function(C){s[B]=C});window.WebSocket.prototype.__defineGetter__(B,function(){return s[B]})}function v(A,B){return{name:A,callback:B}}function k(A){return a.extend(p(A,false),{packet:function(B){return{type:z,action:"message",data:null,uid:B}},startup:function(){var B=this.packet(this.uid,this.url);B.action="startup";B.data=this.url;t.send(JSON.stringify(B))},shutdown:function(){var B=this.packet(this.uid,this.url);B.action="shutdown";t.send(JSON.stringify(B))},send:function(B){var C=this.packet(this.uid,this.url);C.data=B;t.send(JSON.stringify(C));a(this).triggerHandler("send")},start:function(){this.startup();a(this).triggerHandler("open");return this},close:function(){this.shutdown();a(this).triggerHandler("close")}})}function p(B,A){return{type:"WebSocket",uid:y(),readyState:m,bufferedAmount:0,url:B,send:function(C){},start:function(){},close:function(){},onopen:function(){},onerror:function(C){},onclose:function(){},onmessage:function(C){},protocols:{},pipes:{},multiplexed:false,registerPipe:function(D,G,C){var F=new k(D);this.multiplexed=true;for(event in s){if(event!="send"){n(event,F)}}var E=this.pipes[F.uid]=a.extend(F,C).start();return E},unregisterPipe:function(C){this.pipes[C]=null;(this.pipes.length===0)?this.multiplexed=false:void (0)},registerProtocol:function(C,E){var D=new v(C,E);return this.protocols[C]=D},unregisterProtocol:function(C){this.protocols[C]=null},extension:null,protocol:null,reason:null,binaryType:null}}function b(A){var B=jQuery.Event("error");B.data=A;return B}function u(A){var B=jQuery.Event("message");B.data=A;return B}function f(C){var B,A,E={send:function(F){var G=true;a.ajax({async:false,type:d.method,url:C+((d.method=="GET"&&d.arguments)?"?"+a.param(d.arguments):""),data:((d.method=="POST"&&d.arguments)?a.param(a.extend(d.arguments,{data:F})):null),dataType:"text",contentType:"application/x-www-form-urlencoded; charset="+d.encoding,success:function(H){a(E).trigger(new u(g(H,"i",E)))},error:function(J,H,I){G=false;a(E).trigger(b(H))}});return G},close:function(){clearTimeout(A);clearInterval(B);this.readyState=c;a(E).triggerHandler("close")}};function D(){a.ajax({type:d.method,url:C+((d.method=="GET"&&d.arguments)?"?"+a.param(d.arguments):""),dataType:"text",data:((d.method=="POST"&&d.arguments)?a.param(d.arguments):null),success:function(F){a(E).trigger(new u(F))},error:function(H,F,G){success=false;a(E).trigger(b(F))}});a(E).triggerHandler("send")}A=setTimeout(function(){E.readyState=w;D();B=setInterval(D,d.interval);a(E).triggerHandler("open")},d.delay);return E}function e(B,A){for(property in B){if(!A[property]){A[property]=B[property]}}return A}function i(C){var A,B=true;if(window.WebSocket){if(typeof(MozWebSocket)=="function"){A=new MozWebSocket(C)}else{A=new WebSocket(C)}}else{window.WebSocket=f;C=d.http;B=false;A=new WebSocket(C)}return e(p(C,B),A)}var t=i(d.url);for(event in s){n(event,t)}return t}})})(jQuery); \ No newline at end of file diff -r 000000000000 -r f53281280c23 src/pyams_notify/skin/resources/js/pyams_notify.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/pyams_notify/skin/resources/js/pyams_notify.js Thu Jun 02 15:52:52 2016 +0200 @@ -0,0 +1,155 @@ +(function($, globals) { + + "use strict"; + + var MyAMS = globals.MyAMS; + + var PyAMS_notify = { + + connection: null, + + initConnection: function() { + var tcp_handler = $('[data-ams-notify-server]', '#user-activity').data('ams-notify-server'); + if (tcp_handler) { + var ws = $.WebSocket('ws://' + tcp_handler + '/subscribe'); + ws.onopen = PyAMS_notify.onSocketOpened; + ws.onmessage = PyAMS_notify.onSocketMessage; + ws.onerror = PyAMS_notify.onSocketError; + ws.onclose = PyAMS_notify.onSocketClosed; + PyAMS_notify.connection = ws; + setInterval(PyAMS_notify.checkConnection, 30000); + } + }, + + checkConnection: function() { + if ((PyAMS_notify.connection === null) || + (PyAMS_notify.connection.readyState === WebSocket.CLOSED)) { + PyAMS_notify.initConnection(); + } + }, + + onSocketOpened: function(event) { + console.debug("WS subscription connection opened"); + MyAMS.ajax.post('get-notifications-context.json', {}, function(result) { + if (result.principal.id !== '') { + PyAMS_notify.connection.send(JSON.stringify({ + action: 'subscribe', + principal: result.principal, + context: result.context + })); + MyAMS.ajax.post('get-user-notifications.json', {}, PyAMS_notify.showNotifications); + } + }); + }, + + onSocketMessage: function(event) { + var data = JSON.parse(event.data); + PyAMS_notify.notifyOnDesktop(data); + PyAMS_notify.notifyInWebpage(data); + }, + + onSocketError: function(event) { + console.log(event); + }, + + onSocketClosed: function(event) { + PyAMS_notify.connection = null; + console.debug("WS connection closed"); + }, + + notifyOnDesktop: function(data) { + + function do_notify() { + var options = { + title: data.title, + body: data.message, + icon: data.source.avatar + }; + var notification = new Notification(options.title, options); + notification.onclick = function() { + if (data.url) { + window.open(data.url); + } + }; + } + + if (window.Notification && (Notification.permission !== 'denied')) { + if (Notification.permission === 'default') { + Notification.requestPermission(function (status) { + if (status === 'granted') { + do_notify(); + } + }); + } else { + do_notify(); + } + } + }, + + createNotification: function(notification) { + var li = $('
  • '); + var span = $(''); + var link = $('').addClass('msg') + .attr('href', notification.url); + if (notification.source.avatar) { + $('').addClass('air air-top-left margin-top-2') + .attr('src', notification.source.avatar) + .appendTo(link); + } else { + $('').addClass('fa fa-2x fa-user air air-top-left img margin-left-5 margin-top-2') + .appendTo(link); + } + $('').text(notification.timestamp) + .appendTo(link); + $('').addClass('from') + .text(notification.source.title) + .appendTo(link); + $('').addClass('msg-body') + .text(notification.message) + .appendTo(link); + link.appendTo(span); + span.appendTo(li); + return li; + }, + + notifyInWebpage: function(data) { + var badge = $('.badge', '#user-activity >span'); + var count = parseInt(badge.text()); + badge.text(count + 1); + var notifications = $('.notification-body', '#user-activity'); + PyAMS_notify.createNotification(data).prependTo(notifications); + MyAMS.skin.checkNotification(); + }, + + showNotifications: function(data) { + var badge = $('.badge', '#user-activity >span'); + badge.text(data.length); + var notifications = $('.notification-body', '#user-activity'); + notifications.empty(); + if (data.length > 0) { + notifications.prev('p').hide(); + for (var index = 0; index < data.length; index++) { + var notification = data[index]; + PyAMS_notify.createNotification(notification).appendTo(notifications); + } + } else { + notifications.prev('p').show(); + } + MyAMS.skin.checkNotification(); + }, + + refreshNotifications: function() { + return function() { + MyAMS.ajax.post('get-user-notifications.json', {}, PyAMS_notify.showNotifications); + }; + } + }; + globals.PyAMS_notify = PyAMS_notify; + + MyAMS.ajax.check($.WebSocket, + '/--static--/pyams_notify/js/jquery-WebSocket' + MyAMS.devext + '.js', + function() { + PyAMS_notify.initConnection(); + }); + +})(jQuery, this); diff -r 000000000000 -r f53281280c23 src/pyams_notify/skin/resources/js/pyams_notify.min.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/pyams_notify/skin/resources/js/pyams_notify.min.js Thu Jun 02 15:52:52 2016 +0200 @@ -0,0 +1,1 @@ +(function(c,b){var d=b.MyAMS;var a={connection:null,initConnection:function(){var f=c("[data-ams-notify-server]","#user-activity").data("ams-notify-server");if(f){var e=c.WebSocket("ws://"+f+"/subscribe");e.onopen=a.onSocketOpened;e.onmessage=a.onSocketMessage;e.onerror=a.onSocketError;e.onclose=a.onSocketClosed;a.connection=e;setInterval(a.checkConnection,30000)}},checkConnection:function(){if((a.connection===null)||(a.connection.readyState===WebSocket.CLOSED)){a.initConnection()}},onSocketOpened:function(e){console.debug("WS subscription connection opened");d.ajax.post("get-notifications-context.json",{},function(f){if(f.principal.id!==""){a.connection.send(JSON.stringify({action:"subscribe",principal:f.principal,context:f.context}));d.ajax.post("get-user-notifications.json",{},a.showNotifications)}})},onSocketMessage:function(e){var f=JSON.parse(e.data);a.notifyOnDesktop(f);a.notifyInWebpage(f)},onSocketError:function(e){console.log(e)},onSocketClosed:function(e){a.connection=null;console.debug("WS connection closed")},notifyOnDesktop:function(e){function f(){var g={title:e.title,body:e.message,icon:e.source.avatar};var h=new Notification(g.title,g);h.onclick=function(){if(e.url){window.open(e.url)}}}if(window.Notification&&(Notification.permission!=="denied")){if(Notification.permission==="default"){Notification.requestPermission(function(g){if(g==="granted"){f()}})}else{f()}}},createNotification:function(h){var e=c("
  • ");var f=c("");var g=c("").addClass("msg").attr("href",h.url);if(h.source.avatar){c("").addClass("air air-top-left margin-top-2").attr("src",h.source.avatar).appendTo(g)}else{c("").addClass("fa fa-2x fa-user air air-top-left img margin-left-5 margin-top-2").appendTo(g)}c("").text(h.timestamp).appendTo(g);c("").addClass("from").text(h.source.title).appendTo(g);c("").addClass("msg-body").text(h.message).appendTo(g);g.appendTo(f);f.appendTo(e);return e},notifyInWebpage:function(h){var e=c(".badge","#user-activity >span");var g=parseInt(e.text());e.text(g+1);var f=c(".notification-body","#user-activity");a.createNotification(h).prependTo(f);d.skin.checkNotification()},showNotifications:function(h){var e=c(".badge","#user-activity >span");e.text(h.length);var g=c(".notification-body","#user-activity");g.empty();if(h.length>0){g.prev("p").hide();for(var f=0;f +# All Rights Reserved. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE. +# + +""" +Generic Test case for pyams_notify doctest +""" +__docformat__ = 'restructuredtext' + +import unittest +import doctest +import sys +import os + + +current_dir = os.path.dirname(__file__) + +def doc_suite(test_dir, setUp=None, tearDown=None, globs=None): + """Returns a test suite, based on doctests found in /doctest.""" + suite = [] + if globs is None: + globs = globals() + + flags = (doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE | + doctest.REPORT_ONLY_FIRST_FAILURE) + + package_dir = os.path.split(test_dir)[0] + if package_dir not in sys.path: + sys.path.append(package_dir) + + doctest_dir = os.path.join(package_dir, 'doctests') + + # filtering files on extension + docs = [os.path.join(doctest_dir, doc) for doc in + os.listdir(doctest_dir) if doc.endswith('.txt')] + + for test in docs: + suite.append(doctest.DocFileSuite(test, optionflags=flags, + globs=globs, setUp=setUp, + tearDown=tearDown, + module_relative=False)) + + return unittest.TestSuite(suite) + +def test_suite(): + """returns the test suite""" + return doc_suite(current_dir) + +if __name__ == '__main__': + unittest.main(defaultTest='test_suite') + diff -r 000000000000 -r f53281280c23 src/pyams_notify/tests/test_utilsdocstrings.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/pyams_notify/tests/test_utilsdocstrings.py Thu Jun 02 15:52:52 2016 +0200 @@ -0,0 +1,62 @@ +# +# Copyright (c) 2008-2015 Thierry Florac +# All Rights Reserved. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE. +# + +""" +Generic Test case for pyams_notify doc strings +""" +__docformat__ = 'restructuredtext' + +import unittest +import doctest +import sys +import os + + +current_dir = os.path.abspath(os.path.dirname(__file__)) + +def doc_suite(test_dir, globs=None): + """Returns a test suite, based on doc tests strings found in /*.py""" + suite = [] + if globs is None: + globs = globals() + + flags = (doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE | + doctest.REPORT_ONLY_FIRST_FAILURE) + + package_dir = os.path.split(test_dir)[0] + if package_dir not in sys.path: + sys.path.append(package_dir) + + # filtering files on extension + docs = [doc for doc in + os.listdir(package_dir) if doc.endswith('.py')] + docs = [doc for doc in docs if not doc.startswith('__')] + + for test in docs: + fd = open(os.path.join(package_dir, test)) + content = fd.read() + fd.close() + if '>>> ' not in content: + continue + test = test.replace('.py', '') + location = 'pyams_notify.%s' % test + suite.append(doctest.DocTestSuite(location, optionflags=flags, + globs=globs)) + + return unittest.TestSuite(suite) + +def test_suite(): + """returns the test suite""" + return doc_suite(current_dir) + +if __name__ == '__main__': + unittest.main(defaultTest='test_suite') diff -r 000000000000 -r f53281280c23 src/pyams_notify/viewlet/__init__.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/pyams_notify/viewlet/__init__.py Thu Jun 02 15:52:52 2016 +0200 @@ -0,0 +1,41 @@ +# +# Copyright (c) 2008-2015 Thierry Florac +# All Rights Reserved. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE. +# + +__docformat__ = 'restructuredtext' + + +# import standard library + +# import interfaces +from pyams_skin.interfaces.viewlet import IActivityViewletManager +from pyams_skin.layer import IPyAMSLayer + +# import packages +from pyams_skin.viewlet.activity import ActivityViewlet +from pyams_template.template import template_config +from pyams_viewlet.viewlet import viewlet_config + +from pyams_notify import _ + + +@viewlet_config(name='pyams.notifications', layer=IPyAMSLayer, manager=IActivityViewletManager) +@template_config(template='templates/notifications.pt', layer=IPyAMSLayer) +class NotificationsViewlet(ActivityViewlet): + """Notifications viewlet""" + + name = 'notifications' + label = _("Notifications") + url = 'PyAMS_notify.refreshNotifications' + + @property + def notify_server(self): + return self.request.registry.settings.get('pyams_notify.tcp_handler') diff -r 000000000000 -r f53281280c23 src/pyams_notify/viewlet/templates/notifications.pt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/pyams_notify/viewlet/templates/notifications.pt Thu Jun 02 15:52:52 2016 +0200 @@ -0,0 +1,6 @@ +
    +

    No notification to display.

    +
      +
    +
    diff -r 000000000000 -r f53281280c23 src/pyams_notify/views/__init__.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/pyams_notify/views/__init__.py Thu Jun 02 15:52:52 2016 +0200 @@ -0,0 +1,20 @@ +# +# Copyright (c) 2008-2015 Thierry Florac +# All Rights Reserved. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE. +# + +__docformat__ = 'restructuredtext' + + +# import standard library + +# import interfaces + +# import packages diff -r 000000000000 -r f53281280c23 src/pyams_notify/views/context.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/pyams_notify/views/context.py Thu Jun 02 15:52:52 2016 +0200 @@ -0,0 +1,32 @@ +# +# Copyright (c) 2008-2015 Thierry Florac +# All Rights Reserved. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE. +# + + +__docformat__ = 'restructuredtext' + + +# import standard library + +# import interfaces + +# import packages +from pyramid.view import view_config + + +@view_config(name='get-notifications-context.json', renderer='json', xhr=True) +def NotificationsContextView(request): + """Get context for notifications""" + principal = request.principal + return {'principal': {'id': principal.id, + 'title': principal.title, + 'principals': tuple(request.effective_principals)}, + 'context': {'*': ['user.login', 'content.workflow']}} diff -r 000000000000 -r f53281280c23 src/pyams_notify/views/notification.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/pyams_notify/views/notification.py Thu Jun 02 15:52:52 2016 +0200 @@ -0,0 +1,61 @@ +# +# Copyright (c) 2008-2015 Thierry Florac +# All Rights Reserved. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE. +# + +__docformat__ = 'restructuredtext' + + +# import standard library +import pickle +try: + import pylibmc +except ImportError: + pylibmc = None + +# import interfaces +from pyams_notify.interfaces import MEMCACHED_QUEUE_KEY +from pyams_skin.layer import IPyAMSLayer + +# import packages +from pyramid.view import view_config + + +def filtered(notification, request): + """Filter notification against current request""" + return request.principal.id in notification['target'].get('principals', ()) + + +@view_config(name='get-user-notifications.json', request_type=IPyAMSLayer, renderer='json', xhr=True) +class UserNotificationsView(object): + """User notifications view""" + + def __init__(self, request): + self.request = request + self.context = request.context + + @property + def memcached_server(self): + return self.request.registry.settings.get('pyams_notify_ws.memcached_server') + + def __call__(self): + if pylibmc is None: + return () + server = self.memcached_server + if server is not None: + client = pylibmc.Client([server]) + notifications = client.get(MEMCACHED_QUEUE_KEY) + if notifications is None: + return () + else: + return [n for n in reversed(list(filter(lambda x: filtered(x, self.request), + pickle.loads(notifications))))] + else: + return ()