comparison 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
comparison
equal deleted inserted replaced
994:b92b06f023cb 995:f88325b56a6a
31 from libervia.server.constants import Const as C 31 from libervia.server.constants import Const as C
32 from libervia.server import session_iface 32 from libervia.server import session_iface
33 from libervia.server.utils import quote 33 from libervia.server.utils import quote
34 import libervia 34 import libervia
35 35
36 from collections import namedtuple
37 import uuid
36 import os.path 38 import os.path
37 import urllib 39 import urllib
38 import time 40 import time
41
42 WebsocketMeta = namedtuple("WebsocketMeta", ('url', 'token', 'debug'))
39 43
40 44
41 class Cache(object): 45 class Cache(object):
42 46
43 def __init__(self, rendered): 47 def __init__(self, rendered):
64 68
65 class LiberviaPage(web_resource.Resource): 69 class LiberviaPage(web_resource.Resource):
66 isLeaf = True # we handle subpages ourself 70 isLeaf = True # we handle subpages ourself
67 named_pages = {} 71 named_pages = {}
68 uri_callbacks = {} 72 uri_callbacks = {}
73 signals_handlers = {}
69 pages_redirects = {} 74 pages_redirects = {}
70 cache = {} 75 cache = {}
71 # Set of tuples (service/node/sub_id) of nodes subscribed for caching 76 # Set of tuples (service/node/sub_id) of nodes subscribed for caching
72 # sub_id can be empty string if not handled by service 77 # sub_id can be empty string if not handled by service
73 cache_pubsub_sub = set() 78 cache_pubsub_sub = set()
74 main_menu = None 79 main_menu = None
75 80
76 def __init__(self, host, root_dir, url, name=None, redirect=None, access=None, parse_url=None, 81 def __init__(self, host, root_dir, url, name=None, redirect=None, access=None, dynamic=False, parse_url=None,
77 prepare_render=None, render=None, template=None, on_data_post=None): 82 prepare_render=None, render=None, template=None,
83 on_data_post=None, on_data=None, on_signal=None):
78 """initiate LiberviaPages 84 """initiate LiberviaPages
79 85
80 LiberviaPages are the main resources of Libervia, using easy to set python files 86 LiberviaPages are the main resources of Libervia, using easy to set python files
81 The arguments are the variables found in page_meta.py 87 The arguments are the variables found in page_meta.py
82 @param host(Libervia): the running instance of Libervia 88 @param host(Libervia): the running instance of Libervia
94 @param access(unicode, None): permission needed to access the page 100 @param access(unicode, None): permission needed to access the page
95 None means public access. 101 None means public access.
96 Pages inherit from parent pages: e.g. if a "settings" page is restricted to admins, 102 Pages inherit from parent pages: e.g. if a "settings" page is restricted to admins,
97 and if "settings/blog" is public, it still can only be accessed by admins. 103 and if "settings/blog" is public, it still can only be accessed by admins.
98 see C.PAGES_ACCESS_* for details 104 see C.PAGES_ACCESS_* for details
105 @param dynamic(bool): if True, activate websocket for bidirectional communication
99 @param parse_url(callable, None): if set it will be called to handle the URL path 106 @param parse_url(callable, None): if set it will be called to handle the URL path
100 after this method, the page will be rendered if noting is left in path (request.postpath) 107 after this method, the page will be rendered if noting is left in path (request.postpath)
101 else a the request will be transmitted to a subpage 108 else a the request will be transmitted to a subpage
102 @param prepare_render(callable, None): if set, will be used to prepare the rendering 109 @param prepare_render(callable, None): if set, will be used to prepare the rendering
103 that often means gathering data using the bridge 110 that often means gathering data using the bridge
108 This method is mutually exclusive with render 115 This method is mutually exclusive with render
109 @param on_data_post(callable, None): method to call when data is posted 116 @param on_data_post(callable, None): method to call when data is posted
110 None if not post is handled 117 None if not post is handled
111 on_data_post can return a string with following value: 118 on_data_post can return a string with following value:
112 - C.POST_NO_CONFIRM: confirm flag will not be set 119 - C.POST_NO_CONFIRM: confirm flag will not be set
120 @param on_data(callable, None): method to call when dynamic data is sent
121 this method is used with Libervia's websocket mechanism
122 @param on_signal(callable, None): method to call when a registered signal is received
123 this method is used with Libervia's websocket mechanism
113 """ 124 """
114 125
115 web_resource.Resource.__init__(self) 126 web_resource.Resource.__init__(self)
116 self.host = host 127 self.host = host
117 self.root_dir = root_dir 128 self.root_dir = root_dir
128 if access is None: 139 if access is None:
129 access = C.PAGES_ACCESS_PUBLIC 140 access = C.PAGES_ACCESS_PUBLIC
130 if access not in (C.PAGES_ACCESS_PUBLIC, C.PAGES_ACCESS_PROFILE, C.PAGES_ACCESS_NONE): 141 if access not in (C.PAGES_ACCESS_PUBLIC, C.PAGES_ACCESS_PROFILE, C.PAGES_ACCESS_NONE):
131 raise NotImplementedError(_(u"{} access is not implemented yet").format(access)) 142 raise NotImplementedError(_(u"{} access is not implemented yet").format(access))
132 self.access = access 143 self.access = access
144 self.dynamic = dynamic
133 if redirect is not None: 145 if redirect is not None:
134 # only page access and name make sense in case of full redirection 146 # only page access and name make sense in case of full redirection
135 # so we check that rendering methods/values are not set 147 # so we check that rendering methods/values are not set
136 if not all(lambda x: x is not None 148 if not all(lambda x: x is not None
137 for x in (parse_url, prepare_render, render, template)): 149 for x in (parse_url, prepare_render, render, template)):
143 self.parse_url = parse_url 155 self.parse_url = parse_url
144 self.prepare_render = prepare_render 156 self.prepare_render = prepare_render
145 self.template = template 157 self.template = template
146 self.render_method = render 158 self.render_method = render
147 self.on_data_post = on_data_post 159 self.on_data_post = on_data_post
160 self.on_data = on_data
161 self.on_signal = on_signal
148 if access == C.PAGES_ACCESS_NONE: 162 if access == C.PAGES_ACCESS_NONE:
149 # none pages just return a 404, no further check is needed 163 # none pages just return a 404, no further check is needed
150 return 164 return
151 if template is None: 165 if template is None:
152 if not callable(render): 166 if not callable(render):
198 dir_path, 212 dir_path,
199 u'/' + u'/'.join(new_path), 213 u'/' + u'/'.join(new_path),
200 name=page_data.get('name'), 214 name=page_data.get('name'),
201 redirect=page_data.get('redirect'), 215 redirect=page_data.get('redirect'),
202 access=page_data.get('access'), 216 access=page_data.get('access'),
217 dynamic=page_data.get('dynamic', False),
203 parse_url=page_data.get('parse_url'), 218 parse_url=page_data.get('parse_url'),
204 prepare_render=page_data.get('prepare_render'), 219 prepare_render=page_data.get('prepare_render'),
205 render=page_data.get('render'), 220 render=page_data.get('render'),
206 template=page_data.get('template'), 221 template=page_data.get('template'),
207 on_data_post=page_data.get('on_data_post')) 222 on_data_post=page_data.get('on_data_post'),
223 on_data=page_data.get('on_data'),
224 on_signal=page_data.get('on_signal'),
225 )
208 parent.putChild(d, resource) 226 parent.putChild(d, resource)
209 log.info(u"Added /{path} page".format(path=u'[...]/'.join(new_path))) 227 log.info(u"Added /{path} page".format(path=u'[...]/'.join(new_path)))
210 if 'uri_handlers' in page_data: 228 if 'uri_handlers' in page_data:
211 if not isinstance(page_data, dict): 229 if not isinstance(page_data, dict):
212 log.error(_(u'uri_handlers must be a dict')) 230 log.error(_(u'uri_handlers must be a dict'))
255 """ 273 """
256 if uri_tuple in cls.uri_callbacks: 274 if uri_tuple in cls.uri_callbacks:
257 log.info(_(u"{}/{} URIs are already handled, replacing by the new handler").format(*uri_tuple)) 275 log.info(_(u"{}/{} URIs are already handled, replacing by the new handler").format(*uri_tuple))
258 cls.uri_callbacks[uri_tuple] = {u'callback': get_uri_cb, 276 cls.uri_callbacks[uri_tuple] = {u'callback': get_uri_cb,
259 u'pre_path': pre_path} 277 u'pre_path': pre_path}
278
279 def registerSignal(self, request, signal, check_profile=True):
280 r"""register a signal handler
281
282 the page must be dynamic
283 when signal is received, self.on_signal will be called with:
284 - request
285 - signal name
286 - signal arguments
287 signal handler will be removed when connection with dynamic page will be lost
288 @param signal(unicode): name of the signal
289 last arg of signal must be profile, as it will be checked to filter signals
290 @param check_profile(bool): if True, signal profile (which MUST be last arg) will be
291 checked against session profile.
292 /!\ if False, profile will not be checked/filtered, be sure to know what you are doing
293 if you unset this option /!\
294 """
295 # FIXME: add a timeout, if socket is not opened before it, signal handler must be removed
296 if not self.dynamic:
297 log.error(_(u"You can't register signal if page is not dynamic"))
298 return
299 LiberviaPage.signals_handlers.setdefault(signal, {})[id(request)] = (self, request, check_profile)
300 request._signals_registered.append(signal)
260 301
261 def getPagePathFromURI(self, uri): 302 def getPagePathFromURI(self, uri):
262 """Retrieve page URL from xmpp: URI 303 """Retrieve page URL from xmpp: URI
263 304
264 @param uri(unicode): URI with a xmpp: scheme 305 @param uri(unicode): URI with a xmpp: scheme
465 log.warning(_(u"Can't remove watch for {service}/{node}: {msg}").format( 506 log.warning(_(u"Can't remove watch for {service}/{node}: {msg}").format(
466 service=service, node=node, msg=failure_))) 507 service=service, node=node, msg=failure_)))
467 else: 508 else:
468 cache.clear() 509 cache.clear()
469 510
511 @classmethod
512 def onSignal(cls, host, signal, *args):
513 """Generic method which receive registered signals
514
515 if a callback is registered for this signal, call it
516 @param host: Libervia instance
517 @param signal(unicode): name of the signal
518 @param *args: args of the signals
519 """
520 for page, request, check_profile in cls.signals_handlers.get(signal, {}).itervalues():
521 if check_profile:
522 signal_profile = args[-1]
523 request_profile = page.getProfile(request)
524 if not request_profile:
525 # if you want to use signal without session, unset check_profile
526 # (be sure to know what you are doing)
527 log.error(_(u"no session started, signal can't be checked"))
528 continue
529 if signal_profile != request_profile:
530 # we ignore the signal, it's not for our profile
531 continue
532 if request._signals_cache is not None:
533 # socket is not yet opened, we cache the signal
534 request._signals_cache.append((request, signal, args))
535 log.debug(u"signal [{signal}] cached: {args}".format(
536 signal = signal,
537 args = args))
538 else:
539 page.on_signal(page, request, signal, *args)
540
541 def onSocketOpen(self, request):
542 """Called for dynamic pages when socket has just been opened
543
544 we send all cached signals
545 """
546 assert request._signals_cache is not None
547 cache = request._signals_cache
548 request._signals_cache = None
549 for request, signal, args in cache:
550 self.on_signal(self, request, signal, *args)
551
552 def onSocketClose(self, request):
553 """Called for dynamic pages when socket has just been closed
554
555 we remove signal handler
556 """
557 for signal in request._signals_registered:
558 try:
559 del LiberviaPage.signals_handlers[signal][id(request)]
560 except KeyError:
561 log.error(_(u"Can't find signal handler for [{signal}], this should not happen").format(
562 signal = signal))
563 else:
564 log.debug(_(u"Removed signal handler"))
565
470 def HTTPRedirect(self, request, url): 566 def HTTPRedirect(self, request, url):
471 """redirect to an URL using HTTP redirection 567 """redirect to an URL using HTTP redirection
472 568
473 @param request(server.Request): current HTTP request 569 @param request(server.Request): current HTTP request
474 @param url(unicode): url to redirect to 570 @param url(unicode): url to redirect to
602 698
603 return self.host.renderer.render( 699 return self.host.renderer.render(
604 self.template, 700 self.template,
605 root_path = '/templates/', 701 root_path = '/templates/',
606 media_path = '/' + C.MEDIA_DIR, 702 media_path = '/' + C.MEDIA_DIR,
703 cache_path = session_data.cache_dir,
607 main_menu = LiberviaPage.main_menu, 704 main_menu = LiberviaPage.main_menu,
608 **template_data) 705 **template_data)
609 706
610 def _renderEb(self, failure_, request): 707 def _renderEb(self, failure_, request):
611 """don't raise error on CancelError""" 708 """don't raise error on CancelError"""
763 login_url = self.getPageRedirectURL(request) 860 login_url = self.getPageRedirectURL(request)
764 self.HTTPRedirect(request, login_url) 861 self.HTTPRedirect(request, login_url)
765 862
766 return data 863 return data
767 864
865 def renderPartial(self, request, template, template_data):
866 """Render a template to be inserted in dynamic page
867
868 this is NOT the normal page rendering method, it is used only to update
869 dynamic pages
870 @param template(unicode): path of the template to render
871 @param template_data(dict): template_data to use
872 """
873 if not self.dynamic:
874 raise exceptions.InternalError(_(u"renderPartial must only be used with dynamic pages"))
875 session_data = self.host.getSessionData(request, session_iface.ISATSession)
876
877 return self.host.renderer.render(
878 template,
879 root_path = '/templates/',
880 media_path = '/' + C.MEDIA_DIR,
881 cache_path = session_data.cache_dir,
882 main_menu = LiberviaPage.main_menu,
883 **template_data)
884
885 def renderAndUpdate(self, request, template, selectors, template_data_update, update_type="append"):
886 """Helper method to render a partial page element and update the page
887
888 this is NOT the normal page rendering method, it is used only to update
889 dynamic pages
890 @param request(server.Request): current HTTP request
891 @param template: same as for [renderPartial]
892 @param selectors: CSS selectors to use
893 @param template_data_update: template data to use
894 template data cached in request will be copied then updated
895 with this data
896 @parap update_type(unicode): one of:
897 append: append rendered element to selected element
898 """
899 template_data = request.template_data.copy()
900 template_data.update(template_data_update)
901 html = self.renderPartial(request, template, template_data)
902 request.sendData(u'dom',
903 selectors=selectors,
904 update_type=update_type,
905 html=html)
906
768 def renderPage(self, request, skip_parse_url=False): 907 def renderPage(self, request, skip_parse_url=False):
769 """Main method to handle the workflow of a LiberviaPage""" 908 """Main method to handle the workflow of a LiberviaPage"""
770 909
771 # template_data are the variables passed to template 910 # template_data are the variables passed to template
772 if not hasattr(request, 'template_data'): 911 if not hasattr(request, 'template_data'):
773 session_data = self.host.getSessionData(request, session_iface.ISATSession) 912 session_data = self.host.getSessionData(request, session_iface.ISATSession)
774 csrf_token = session_data.csrf_token 913 csrf_token = session_data.csrf_token
775 request.template_data = {u'profile': session_data.profile, 914 request.template_data = {u'profile': session_data.profile,
776 u'csrf_token': csrf_token} 915 u'csrf_token': csrf_token}
916 if self.dynamic:
917 # we need to activate dynamic page
918 # we set data for template, and create/register token
919 socket_token = unicode(uuid.uuid4())
920 socket_url = self.host.getWebsocketURL(request)
921 socket_debug = C.boolConst(self.host.debug)
922 request.template_data['websocket'] = WebsocketMeta(socket_url, socket_token, socket_debug)
923 self.host.registerWSToken(socket_token, self, request)
924 # we will keep track of handlers to remove
925 request._signals_registered = []
926 # we will cache registered signals until socket is opened
927 request._signals_cache = []
777 928
778 # XXX: here is the code which need to be executed once 929 # XXX: here is the code which need to be executed once
779 # at the beginning of the request hanling 930 # at the beginning of the request hanling
780 if request.postpath and not request.postpath[-1]: 931 if request.postpath and not request.postpath[-1]:
781 # we don't differenciate URLs finishing with '/' or not 932 # we don't differenciate URLs finishing with '/' or not