diff src/server/pages.py @ 995:f88325b56a6a

server: dynamic pages first draft: /!\ new dependency: autobahn This patch introduce server part of dynamic pages. Dynamic pages use websockets to establish constant connection with a Libervia page, allowing to receive real time data or update it. The feature is activated by specifying "dynamic = true" in the page. Once activated, page can implement "on_data" method which will be called when data are sent by the page. To send data the other way, the page can use request.sendData. The new "registerSignal" method allows to use an "on_signal" method to be called each time given signal is received, with automatic (and optional) filtering on profile. New renderPartial and renderAndUpdate method allow to append new HTML elements to the dynamic page.
author Goffi <goffi@goffi.org>
date Wed, 03 Jan 2018 01:10:12 +0100
parents b92b06f023cb
children 0848b8b0188d
line wrap: on
line diff
--- a/src/server/pages.py	Wed Dec 13 00:37:12 2017 +0100
+++ b/src/server/pages.py	Wed Jan 03 01:10:12 2018 +0100
@@ -33,10 +33,14 @@
 from libervia.server.utils import quote
 import libervia
 
+from collections import namedtuple
+import uuid
 import os.path
 import urllib
 import time
 
+WebsocketMeta = namedtuple("WebsocketMeta", ('url', 'token', 'debug'))
+
 
 class Cache(object):
 
@@ -66,6 +70,7 @@
     isLeaf = True  # we handle subpages ourself
     named_pages = {}
     uri_callbacks = {}
+    signals_handlers = {}
     pages_redirects = {}
     cache = {}
     # Set of tuples (service/node/sub_id) of nodes subscribed for caching
@@ -73,8 +78,9 @@
     cache_pubsub_sub = set()
     main_menu = None
 
-    def __init__(self, host, root_dir, url, name=None, redirect=None, access=None, parse_url=None,
-                 prepare_render=None, render=None, template=None, on_data_post=None):
+    def __init__(self, host, root_dir, url, name=None, redirect=None, access=None, dynamic=False, parse_url=None,
+                 prepare_render=None, render=None, template=None,
+                 on_data_post=None, on_data=None, on_signal=None):
         """initiate LiberviaPages
 
         LiberviaPages are the main resources of Libervia, using easy to set python files
@@ -96,6 +102,7 @@
             Pages inherit from parent pages: e.g. if a "settings" page is restricted to admins,
             and if "settings/blog" is public, it still can only be accessed by admins.
             see C.PAGES_ACCESS_* for details
+        @param dynamic(bool): if True, activate websocket for bidirectional communication
         @param parse_url(callable, None): if set it will be called to handle the URL path
             after this method, the page will be rendered if noting is left in path (request.postpath)
             else a the request will be transmitted to a subpage
@@ -110,6 +117,10 @@
             None if not post is handled
             on_data_post can return a string with following value:
                 - C.POST_NO_CONFIRM: confirm flag will not be set
+        @param on_data(callable, None): method to call when dynamic data is sent
+            this method is used with Libervia's websocket mechanism
+        @param on_signal(callable, None): method to call when a registered signal is received
+            this method is used with Libervia's websocket mechanism
         """
 
         web_resource.Resource.__init__(self)
@@ -130,6 +141,7 @@
         if access not in (C.PAGES_ACCESS_PUBLIC, C.PAGES_ACCESS_PROFILE, C.PAGES_ACCESS_NONE):
             raise NotImplementedError(_(u"{} access is not implemented yet").format(access))
         self.access = access
+        self.dynamic = dynamic
         if redirect is not None:
             # only page access and name make sense in case of full redirection
             # so we check that rendering methods/values are not set
@@ -145,6 +157,8 @@
         self.template = template
         self.render_method = render
         self.on_data_post = on_data_post
+        self.on_data = on_data
+        self.on_signal = on_signal
         if access == C.PAGES_ACCESS_NONE:
             # none pages just return a 404, no further check is needed
             return
@@ -200,11 +214,15 @@
                     name=page_data.get('name'),
                     redirect=page_data.get('redirect'),
                     access=page_data.get('access'),
+                    dynamic=page_data.get('dynamic', False),
                     parse_url=page_data.get('parse_url'),
                     prepare_render=page_data.get('prepare_render'),
                     render=page_data.get('render'),
                     template=page_data.get('template'),
-                    on_data_post=page_data.get('on_data_post'))
+                    on_data_post=page_data.get('on_data_post'),
+                    on_data=page_data.get('on_data'),
+                    on_signal=page_data.get('on_signal'),
+                    )
                 parent.putChild(d, resource)
                 log.info(u"Added /{path} page".format(path=u'[...]/'.join(new_path)))
                 if 'uri_handlers' in page_data:
@@ -258,6 +276,29 @@
         cls.uri_callbacks[uri_tuple] = {u'callback': get_uri_cb,
                                         u'pre_path': pre_path}
 
+    def registerSignal(self, request, signal, check_profile=True):
+        r"""register a signal handler
+
+        the page must be dynamic
+        when signal is received, self.on_signal will be called with:
+            - request
+            - signal name
+            - signal arguments
+        signal handler will be removed when connection with dynamic page will be lost
+        @param signal(unicode): name of the signal
+            last arg of signal must be profile, as it will be checked to filter signals
+        @param check_profile(bool): if True, signal profile (which MUST be last arg) will be
+            checked against session profile.
+            /!\ if False, profile will not be checked/filtered, be sure to know what you are doing
+                if you unset this option /!\
+        """
+        # FIXME: add a timeout, if socket is not opened before it, signal handler must be removed
+        if not self.dynamic:
+            log.error(_(u"You can't register signal if page is not dynamic"))
+            return
+        LiberviaPage.signals_handlers.setdefault(signal, {})[id(request)] = (self, request, check_profile)
+        request._signals_registered.append(signal)
+
     def getPagePathFromURI(self, uri):
         """Retrieve page URL from xmpp: URI
 
@@ -467,6 +508,61 @@
         else:
             cache.clear()
 
+    @classmethod
+    def onSignal(cls, host, signal, *args):
+        """Generic method which receive registered signals
+
+        if a callback is registered for this signal, call it
+        @param host: Libervia instance
+        @param signal(unicode): name of the signal
+        @param *args: args of the signals
+        """
+        for page, request, check_profile in cls.signals_handlers.get(signal, {}).itervalues():
+            if check_profile:
+                signal_profile = args[-1]
+                request_profile = page.getProfile(request)
+                if not request_profile:
+                    # if you want to use signal without session, unset check_profile
+                    # (be sure to know what you are doing)
+                    log.error(_(u"no session started, signal can't be checked"))
+                    continue
+                if signal_profile != request_profile:
+                    # we ignore the signal, it's not for our profile
+                    continue
+            if request._signals_cache is not None:
+                # socket is not yet opened, we cache the signal
+                request._signals_cache.append((request, signal, args))
+                log.debug(u"signal [{signal}] cached: {args}".format(
+                    signal = signal,
+                    args = args))
+            else:
+                page.on_signal(page, request, signal, *args)
+
+    def onSocketOpen(self, request):
+        """Called for dynamic pages when socket has just been opened
+
+        we send all cached signals
+        """
+        assert request._signals_cache is not None
+        cache = request._signals_cache
+        request._signals_cache = None
+        for request, signal, args in cache:
+            self.on_signal(self, request, signal, *args)
+
+    def onSocketClose(self, request):
+        """Called for dynamic pages when socket has just been closed
+
+        we remove signal handler
+        """
+        for signal in request._signals_registered:
+            try:
+                del LiberviaPage.signals_handlers[signal][id(request)]
+            except KeyError:
+                log.error(_(u"Can't find signal handler for [{signal}], this should not happen").format(
+                    signal = signal))
+            else:
+                log.debug(_(u"Removed signal handler"))
+
     def HTTPRedirect(self, request, url):
         """redirect to an URL using HTTP redirection
 
@@ -604,6 +700,7 @@
             self.template,
             root_path = '/templates/',
             media_path = '/' + C.MEDIA_DIR,
+            cache_path = session_data.cache_dir,
             main_menu = LiberviaPage.main_menu,
             **template_data)
 
@@ -765,6 +862,48 @@
 
         return data
 
+    def renderPartial(self, request, template, template_data):
+        """Render a template to be inserted in dynamic page
+
+        this is NOT the normal page rendering method, it is used only to update
+        dynamic pages
+        @param template(unicode): path of the template to render
+        @param template_data(dict): template_data to use
+        """
+        if not self.dynamic:
+            raise exceptions.InternalError(_(u"renderPartial must only be used with dynamic pages"))
+        session_data = self.host.getSessionData(request, session_iface.ISATSession)
+
+        return self.host.renderer.render(
+            template,
+            root_path = '/templates/',
+            media_path = '/' + C.MEDIA_DIR,
+            cache_path = session_data.cache_dir,
+            main_menu = LiberviaPage.main_menu,
+            **template_data)
+
+    def renderAndUpdate(self, request, template, selectors, template_data_update, update_type="append"):
+        """Helper method to render a partial page element and update the page
+
+        this is NOT the normal page rendering method, it is used only to update
+        dynamic pages
+        @param request(server.Request): current HTTP request
+        @param template: same as for [renderPartial]
+        @param selectors: CSS selectors to use
+        @param template_data_update: template data to use
+            template data cached in request will be copied then updated
+            with this data
+        @parap update_type(unicode): one of:
+            append: append rendered element to selected element
+        """
+        template_data = request.template_data.copy()
+        template_data.update(template_data_update)
+        html = self.renderPartial(request, template, template_data)
+        request.sendData(u'dom',
+                        selectors=selectors,
+                        update_type=update_type,
+                        html=html)
+
     def renderPage(self, request, skip_parse_url=False):
         """Main method to handle the workflow of a LiberviaPage"""
 
@@ -774,6 +913,18 @@
             csrf_token = session_data.csrf_token
             request.template_data = {u'profile': session_data.profile,
                                      u'csrf_token': csrf_token}
+            if self.dynamic:
+                # we need to activate dynamic page
+                # we set data for template, and create/register token
+                socket_token = unicode(uuid.uuid4())
+                socket_url = self.host.getWebsocketURL(request)
+                socket_debug = C.boolConst(self.host.debug)
+                request.template_data['websocket'] = WebsocketMeta(socket_url, socket_token, socket_debug)
+                self.host.registerWSToken(socket_token, self, request)
+                # we will keep track of handlers to remove
+                request._signals_registered = []
+                # we will cache registered signals until socket is opened
+                request._signals_cache = []
 
             # XXX: here is the code which need to be executed once
             #      at the beginning of the request hanling