Mercurial > libervia-web
comparison libervia/server/pages.py @ 1504:409d10211b20
server, browser: dynamic pages refactoring:
dynamic pages has been reworked, to change the initial basic implementation.
Pages are now dynamic by default, and a websocket is established by the first connected
page of a session. The socket is used to transmit bridge signals, and then the signal is
broadcasted to other tabs using broadcast channel.
If the connecting tab is closed, an other one is chosen.
Some tests are made to retry connecting in case of problem, and sometimes reload the pages
(e.g. if profile is connected).
Signals (or other data) are cached during reconnection phase, to avoid lost of data.
All previous partial rendering mechanism have been removed, chat page is temporarily not
working anymore, but will be eventually redone (one of the goal of this work is to have
proper chat).
author | Goffi <goffi@goffi.org> |
---|---|
date | Wed, 01 Mar 2023 18:02:44 +0100 |
parents | 1671d187e71d |
children | ce879da7fcf7 |
comparison
equal
deleted
inserted
replaced
1503:2796e73ed50c | 1504:409d10211b20 |
---|---|
110 request.postpath = self._postpath[:] | 110 request.postpath = self._postpath[:] |
111 | 111 |
112 | 112 |
113 class LiberviaPage(web_resource.Resource): | 113 class LiberviaPage(web_resource.Resource): |
114 isLeaf = True # we handle subpages ourself | 114 isLeaf = True # we handle subpages ourself |
115 signals_handlers = {} | |
116 cache = {} | 115 cache = {} |
117 # Set of tuples (service/node/sub_id) of nodes subscribed for caching | 116 # Set of tuples (service/node/sub_id) of nodes subscribed for caching |
118 # sub_id can be empty string if not handled by service | 117 # sub_id can be empty string if not handled by service |
119 cache_pubsub_sub = set() | 118 cache_pubsub_sub = set() |
120 | 119 |
121 def __init__( | 120 def __init__( |
122 self, host, vhost_root, root_dir, url, name=None, label=None, redirect=None, | 121 self, host, vhost_root, root_dir, url, name=None, label=None, redirect=None, |
123 access=None, dynamic=False, parse_url=None, add_breadcrumb=None, | 122 access=None, dynamic=True, parse_url=None, add_breadcrumb=None, |
124 prepare_render=None, render=None, template=None, on_data_post=None, on_data=None, | 123 prepare_render=None, render=None, template=None, on_data_post=None, on_data=None, |
125 on_signal=None, url_cache=False, replace_on_conflict=False | 124 url_cache=False, replace_on_conflict=False |
126 ): | 125 ): |
127 """Initiate LiberviaPage instance | 126 """Initiate LiberviaPage instance |
128 | 127 |
129 LiberviaPages are the main resources of Libervia, using easy to set python files | 128 LiberviaPages are the main resources of Libervia, using easy to set python files |
130 The non mandatory arguments are the variables found in page_meta.py | 129 The non mandatory arguments are the variables found in page_meta.py |
171 on_data_post can raise following exceptions: | 170 on_data_post can raise following exceptions: |
172 - exceptions.DataError: value is incorrect, message will be displayed | 171 - exceptions.DataError: value is incorrect, message will be displayed |
173 as a notification | 172 as a notification |
174 @param on_data(callable, None): method to call when dynamic data is sent | 173 @param on_data(callable, None): method to call when dynamic data is sent |
175 this method is used with Libervia's websocket mechanism | 174 this method is used with Libervia's websocket mechanism |
176 @param on_signal(callable, None): method to call when a registered signal is | |
177 received. This method is used with Libervia's websocket mechanism | |
178 @param url_cache(boolean): if set, result of parse_url is cached (per profile). | 175 @param url_cache(boolean): if set, result of parse_url is cached (per profile). |
179 Useful when costly calls (e.g. network) are done while parsing URL. | 176 Useful when costly calls (e.g. network) are done while parsing URL. |
180 @param replace_on_conflict(boolean): if True, don't raise ConflictError if a | 177 @param replace_on_conflict(boolean): if True, don't raise ConflictError if a |
181 page of this name already exists, but replace it | 178 page of this name already exists, but replace it |
182 """ | 179 """ |
229 self.prepare_render = prepare_render | 226 self.prepare_render = prepare_render |
230 self.template = template | 227 self.template = template |
231 self.render_method = render | 228 self.render_method = render |
232 self.on_data_post = on_data_post | 229 self.on_data_post = on_data_post |
233 self.on_data = on_data | 230 self.on_data = on_data |
234 self.on_signal = on_signal | |
235 self.url_cache = url_cache | 231 self.url_cache = url_cache |
236 if access == C.PAGES_ACCESS_NONE: | 232 if access == C.PAGES_ACCESS_NONE: |
237 # none pages just return a 404, no further check is needed | 233 # none pages just return a 404, no further check is needed |
238 return | 234 return |
239 if template is not None and render is not None: | 235 if template is not None and render is not None: |
303 url="/" + "/".join(url_elts), | 299 url="/" + "/".join(url_elts), |
304 name=page_data.get("name"), | 300 name=page_data.get("name"), |
305 label=page_data.get("label"), | 301 label=page_data.get("label"), |
306 redirect=page_data.get("redirect"), | 302 redirect=page_data.get("redirect"), |
307 access=page_data.get("access"), | 303 access=page_data.get("access"), |
308 dynamic=page_data.get("dynamic", False), | 304 dynamic=page_data.get("dynamic", True), |
309 parse_url=page_data.get("parse_url"), | 305 parse_url=page_data.get("parse_url"), |
310 add_breadcrumb=page_data.get("add_breadcrumb"), | 306 add_breadcrumb=page_data.get("add_breadcrumb"), |
311 prepare_render=page_data.get("prepare_render"), | 307 prepare_render=page_data.get("prepare_render"), |
312 render=page_data.get("render"), | 308 render=page_data.get("render"), |
313 template=page_data.get("template"), | 309 template=page_data.get("template"), |
314 on_data_post=page_data.get("on_data_post"), | 310 on_data_post=page_data.get("on_data_post"), |
315 on_data=page_data.get("on_data"), | 311 on_data=page_data.get("on_data"), |
316 on_signal=page_data.get("on_signal"), | |
317 url_cache=page_data.get("url_cache", False), | 312 url_cache=page_data.get("url_cache", False), |
318 replace_on_conflict=replace_on_conflict | 313 replace_on_conflict=replace_on_conflict |
319 ) | 314 ) |
320 | 315 |
321 @staticmethod | 316 @staticmethod |
322 def createBrowserData( | 317 def createBrowserData( |
323 vhost_root, | 318 vhost_root, |
324 resource: Optional(LiberviaPage), | 319 resource: Optional[LiberviaPage], |
325 browser_path: Path, | 320 browser_path: Path, |
326 path_elts: Optional(List[str]), | 321 path_elts: Optional[List[str]], |
327 engine: str = "brython" | 322 engine: str = "brython" |
328 ) -> None: | 323 ) -> None: |
329 """create and store data for browser dynamic code""" | 324 """create and store data for browser dynamic code""" |
330 dyn_data = { | 325 dyn_data = { |
331 "path": browser_path, | 326 "path": browser_path, |
603 if uri_tuple in self.uri_callbacks: | 598 if uri_tuple in self.uri_callbacks: |
604 log.info(_("{}/{} URIs are already handled, replacing by the new handler") | 599 log.info(_("{}/{} URIs are already handled, replacing by the new handler") |
605 .format( *uri_tuple)) | 600 .format( *uri_tuple)) |
606 self.uri_callbacks[uri_tuple] = (self, get_uri_cb) | 601 self.uri_callbacks[uri_tuple] = (self, get_uri_cb) |
607 | 602 |
608 def getSignalId(self, request): | |
609 """Retrieve signal_id for a request | |
610 | |
611 signal_id is used for dynamic page, to associate a initial request with a | |
612 signal handler. For WebsocketRequest, signal_id attribute is used (which must | |
613 be orginal request's id) | |
614 For server.Request it's id(request) | |
615 """ | |
616 return getattr(request, 'signal_id', id(request)) | |
617 | |
618 def registerSignal(self, request, signal, check_profile=True): | |
619 r"""register a signal handler | |
620 | |
621 the page must be dynamic | |
622 when signal is received, self.on_signal will be called with: | |
623 - request | |
624 - signal name | |
625 - signal arguments | |
626 signal handler will be removed when connection with dynamic page will be lost | |
627 @param signal(unicode): name of the signal | |
628 last arg of signal must be profile, as it will be checked to filter signals | |
629 @param check_profile(bool): if True, signal profile (which MUST be last arg) | |
630 will be checked against session profile. | |
631 /!\ if False, profile will not be checked/filtered, be sure to know what you | |
632 are doing if you unset this option /!\ | |
633 """ | |
634 # FIXME: add a timeout; if socket is not opened before it, signal handler | |
635 # must be removed | |
636 if not self.dynamic: | |
637 log.error(_("You can't register signal if page is not dynamic")) | |
638 return | |
639 signal_id = self.getSignalId(request) | |
640 LiberviaPage.signals_handlers.setdefault(signal, {})[signal_id] = [ | |
641 self, | |
642 request, | |
643 check_profile, | |
644 ] | |
645 request._signals_registered.append(signal) | |
646 | |
647 def getConfig(self, key, default=None, value_type=None): | 603 def getConfig(self, key, default=None, value_type=None): |
648 return self.host.getConfig(self.vhost_root, key=key, default=default, | 604 return self.host.getConfig(self.vhost_root, key=key, default=default, |
649 value_type=value_type) | 605 value_type=value_type) |
650 | 606 |
651 def getBuildPath(self, session_data): | 607 def getBuildPath(self, session_data): |
1211 'identityGet', e, [], True, profile) | 1167 'identityGet', e, [], True, profile) |
1212 identities[e] = data_format.deserialise(id_raw) | 1168 identities[e] = data_format.deserialise(id_raw) |
1213 | 1169 |
1214 # signals, server => browser communication | 1170 # signals, server => browser communication |
1215 | 1171 |
1216 @classmethod | |
1217 def onSignal(cls, host, signal, *args): | |
1218 """Generic method which receive registered signals | |
1219 | |
1220 if a callback is registered for this signal, call it | |
1221 @param host: Libervia instance | |
1222 @param signal(unicode): name of the signal | |
1223 @param *args: args of the signals | |
1224 """ | |
1225 for page, request, check_profile in cls.signals_handlers.get( | |
1226 signal, {} | |
1227 ).values(): | |
1228 if check_profile: | |
1229 signal_profile = args[-1] | |
1230 request_profile = page.getProfile(request) | |
1231 if not request_profile: | |
1232 # if you want to use signal without session, unset check_profile | |
1233 # (be sure to know what you are doing) | |
1234 log.error(_("no session started, signal can't be checked")) | |
1235 continue | |
1236 if signal_profile != request_profile: | |
1237 # we ignore the signal, it's not for our profile | |
1238 continue | |
1239 if request._signals_cache is not None: | |
1240 # socket is not yet opened, we cache the signal | |
1241 request._signals_cache.append((request, signal, args)) | |
1242 log.debug( | |
1243 "signal [{signal}] cached: {args}".format(signal=signal, args=args) | |
1244 ) | |
1245 else: | |
1246 page.on_signal(page, request, signal, *args) | |
1247 | |
1248 def onSocketOpen(self, request): | |
1249 """Called for dynamic pages when socket has just been opened | |
1250 | |
1251 we send all cached signals | |
1252 """ | |
1253 assert request._signals_cache is not None | |
1254 # we need to replace corresponding original requests by this websocket request | |
1255 # in signals_handlers | |
1256 signal_id = request.signal_id | |
1257 for signal_handlers_map in self.__class__.signals_handlers.values(): | |
1258 if signal_id in signal_handlers_map: | |
1259 signal_handlers_map[signal_id][1] = request | |
1260 | |
1261 cache = request._signals_cache | |
1262 request._signals_cache = None | |
1263 for request, signal, args in cache: | |
1264 self.on_signal(self, request, signal, *args) | |
1265 | |
1266 def onSocketClose(self, request): | |
1267 """Called for dynamic pages when socket has just been closed | |
1268 | |
1269 we remove signal handler | |
1270 """ | |
1271 for signal in request._signals_registered: | |
1272 signal_id = self.getSignalId(request) | |
1273 try: | |
1274 del LiberviaPage.signals_handlers[signal][signal_id] | |
1275 except KeyError: | |
1276 log.error(_("Can't find signal handler for [{signal}], this should not " | |
1277 "happen").format(signal=signal)) | |
1278 else: | |
1279 log.debug(_("Removed signal handler")) | |
1280 | |
1281 def delegateToResource(self, request, resource): | 1172 def delegateToResource(self, request, resource): |
1282 """continue workflow with Twisted Resource""" | 1173 """continue workflow with Twisted Resource""" |
1283 buf = resource.render(request) | 1174 buf = resource.render(request) |
1284 if buf == server.NOT_DONE_YET: | 1175 if buf == server.NOT_DONE_YET: |
1285 pass | 1176 pass |
1445 else: | 1336 else: |
1446 child.render(request) | 1337 child.render(request) |
1447 raise failure.Failure(exceptions.CancelError("subpage page is used")) | 1338 raise failure.Failure(exceptions.CancelError("subpage page is used")) |
1448 | 1339 |
1449 def _prepare_dynamic(self, request): | 1340 def _prepare_dynamic(self, request): |
1341 session_data = self.host.getSessionData(request, session_iface.ISATSession) | |
1450 # we need to activate dynamic page | 1342 # we need to activate dynamic page |
1451 # we set data for template, and create/register token | 1343 # we set data for template, and create/register token |
1452 socket_token = str(uuid.uuid4()) | 1344 # socket_token = str(uuid.uuid4()) |
1453 socket_url = self.host.getWebsocketURL(request) | 1345 socket_url = self.host.get_websocket_url(request) |
1346 # as for CSRF, it is important to not let the socket token if we use the service | |
1347 # profile, as those pages can be cached, and then the token leaked. | |
1348 socket_token = '' if session_data.profile is None else session_data.ws_token | |
1454 socket_debug = C.boolConst(self.host.debug) | 1349 socket_debug = C.boolConst(self.host.debug) |
1455 request.template_data["websocket"] = WebsocketMeta( | 1350 request.template_data["websocket"] = WebsocketMeta( |
1456 socket_url, socket_token, socket_debug | 1351 socket_url, socket_token, socket_debug |
1457 ) | 1352 ) |
1458 # we will keep track of handlers to remove | 1353 # we will keep track of handlers to remove |
1459 request._signals_registered = [] | 1354 request._signals_registered = [] |
1460 # we will cache registered signals until socket is opened | 1355 # we will cache registered signals until socket is opened |
1461 request._signals_cache = [] | 1356 request._signals_cache = [] |
1462 self.host.registerWSToken(socket_token, self, request) | |
1463 | 1357 |
1464 def _render_template(self, request): | 1358 def _render_template(self, request): |
1465 template_data = request.template_data | 1359 template_data = request.template_data |
1466 | 1360 |
1467 # if confirm variable is set in case of successfuly data post | 1361 # if confirm variable is set in case of successfuly data post |
1757 session_data = self.host.getSessionData(request, | 1651 session_data = self.host.getSessionData(request, |
1758 session_iface.ISATSession) | 1652 session_iface.ISATSession) |
1759 session_data.locale = a | 1653 session_data.locale = a |
1760 return | 1654 return |
1761 | 1655 |
1762 def renderPartial(self, request, template, template_data): | |
1763 """Render a template to be inserted in dynamic page | |
1764 | |
1765 this is NOT the normal page rendering method, it is used only to update | |
1766 dynamic pages | |
1767 @param template(unicode): path of the template to render | |
1768 @param template_data(dict): template_data to use | |
1769 """ | |
1770 if not self.dynamic: | |
1771 raise exceptions.InternalError( | |
1772 _("renderPartial must only be used with dynamic pages") | |
1773 ) | |
1774 session_data = self.host.getSessionData(request, session_iface.ISATSession) | |
1775 if session_data.locale is not None: | |
1776 template_data['locale'] = session_data.locale | |
1777 if self.vhost_root.site_name: | |
1778 template_data['site'] = self.vhost_root.site_name | |
1779 | |
1780 return self.host.renderer.render( | |
1781 template, | |
1782 theme=session_data.theme or self.default_theme, | |
1783 site_themes=self.site_themes, | |
1784 page_url=self.getURL(), | |
1785 media_path=f"/{C.MEDIA_DIR}", | |
1786 build_path=f"/{C.BUILD_DIR}/", | |
1787 cache_path=session_data.cache_dir, | |
1788 main_menu=self.main_menu, | |
1789 **template_data | |
1790 ) | |
1791 | |
1792 def renderAndUpdate( | |
1793 self, request, template, selectors, template_data_update, update_type="append" | |
1794 ): | |
1795 """Helper method to render a partial page element and update the page | |
1796 | |
1797 this is NOT the normal page rendering method, it is used only to update | |
1798 dynamic pages | |
1799 @param request(server.Request): current HTTP request | |
1800 @param template: same as for [renderPartial] | |
1801 @param selectors: CSS selectors to use | |
1802 @param template_data_update: template data to use | |
1803 template data cached in request will be copied then updated | |
1804 with this data | |
1805 @parap update_type(unicode): one of: | |
1806 append: append rendered element to selected element | |
1807 """ | |
1808 template_data = request.template_data.copy() | |
1809 template_data.update(template_data_update) | |
1810 html = self.renderPartial(request, template, template_data) | |
1811 try: | |
1812 request.sendData( | |
1813 "dom", selectors=selectors, update_type=update_type, html=html) | |
1814 except Exception as e: | |
1815 log.error("Can't renderAndUpdate, html was: {html}".format(html=html)) | |
1816 raise e | |
1817 | |
1818 async def renderPage(self, request, skip_parse_url=False): | 1656 async def renderPage(self, request, skip_parse_url=False): |
1819 """Main method to handle the workflow of a LiberviaPage""" | 1657 """Main method to handle the workflow of a LiberviaPage""" |
1820 # template_data are the variables passed to template | 1658 # template_data are the variables passed to template |
1821 if not hasattr(request, "template_data"): | 1659 if not hasattr(request, "template_data"): |
1822 # if template_data doesn't exist, it's the beginning of the request workflow | 1660 # if template_data doesn't exist, it's the beginning of the request workflow |