comparison libervia/backend/tools/common/template.py @ 4071:4b842c1fb686

refactoring: renamed `sat` package to `libervia.backend`
author Goffi <goffi@goffi.org>
date Fri, 02 Jun 2023 11:49:51 +0200
parents sat/tools/common/template.py@524856bd7b19
children 47401850dec6
comparison
equal deleted inserted replaced
4070:d10748475025 4071:4b842c1fb686
1 #!/usr/bin/env python3
2
3 # SAT: an XMPP client
4 # Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
5
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU Affero General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
10
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU Affero General Public License for more details.
15
16 # You should have received a copy of the GNU Affero General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
18
19 """Template generation"""
20
21 import os.path
22 import time
23 import re
24 import json
25 from datetime import datetime
26 from pathlib import Path
27 from collections import namedtuple
28 from typing import Optional, List, Tuple, Union
29 from xml.sax.saxutils import quoteattr
30 from babel import support
31 from babel import Locale
32 from babel.core import UnknownLocaleError
33 import pygments
34 from pygments import lexers
35 from pygments import formatters
36 from libervia.backend.core.constants import Const as C
37 from libervia.backend.core.i18n import _
38 from libervia.backend.core import exceptions
39 from libervia.backend.tools import config
40 from libervia.backend.tools.common import date_utils
41 from libervia.backend.core.log import getLogger
42
43 log = getLogger(__name__)
44
45 try:
46 import sat_templates
47 except ImportError:
48 raise exceptions.MissingModule(
49 "sat_templates module is not available, please install it or check your path to "
50 "use template engine"
51 )
52 else:
53 sat_templates # to avoid pyflakes warning
54
55 try:
56 import jinja2
57 except:
58 raise exceptions.MissingModule(
59 "Missing module jinja2, please install it from http://jinja.pocoo.org or with "
60 "pip install jinja2"
61 )
62
63 from lxml import etree
64 from jinja2 import Markup as safe
65 from jinja2 import is_undefined
66 from jinja2 import utils
67 from jinja2 import TemplateNotFound
68 from jinja2 import contextfilter
69 from jinja2.loaders import split_template_path
70
71 HTML_EXT = ("html", "xhtml")
72 RE_ATTR_ESCAPE = re.compile(r"[^a-z_-]")
73 SITE_RESERVED_NAMES = ("sat",)
74 TPL_RESERVED_CHARS = r"()/."
75 RE_TPL_RESERVED_CHARS = re.compile("[" + TPL_RESERVED_CHARS + "]")
76 BROWSER_DIR = "_browser"
77 BROWSER_META_FILE = "browser_meta.json"
78
79 TemplateData = namedtuple("TemplateData", ['site', 'theme', 'path'])
80
81
82 class TemplateLoader(jinja2.BaseLoader):
83 """A template loader which handle site, theme and absolute paths"""
84 # TODO: list_templates should be implemented
85
86 def __init__(self, sites_paths, sites_themes, trusted=False):
87 """
88 @param trusted(bool): if True, absolue template paths will be allowed
89 be careful when using this option and sure that you can trust the template,
90 as this allow the template to open any file on the system that the
91 launching user can access.
92 """
93 if not sites_paths or not "" in sites_paths:
94 raise exceptions.InternalError("Invalid sites_paths")
95 super(jinja2.BaseLoader, self).__init__()
96 self.sites_paths = sites_paths
97 self.sites_themes = sites_themes
98 self.trusted = trusted
99
100 @staticmethod
101 def parse_template(template):
102 """Parse template path and return site, theme and path
103
104 @param template_path(unicode): path to template with parenthesis syntax
105 The site and/or theme can be specified in parenthesis just before the path
106 e.g.: (some_theme)path/to/template.html
107 (/some_theme)path/to/template.html (equivalent to previous one)
108 (other_site/other_theme)path/to/template.html
109 (other_site/)path/to/template.html (defaut theme for other_site)
110 /absolute/path/to/template.html (in trusted environment only)
111 @return (TemplateData):
112 site, theme and template_path.
113 if site is empty, SàT Templates are used
114 site and theme can be both None if absolute path is used
115 Relative path is the path from theme root dir e.g. blog/articles.html
116 """
117 if template.startswith("("):
118 # site and/or theme are specified
119 try:
120 theme_end = template.index(")")
121 except IndexError:
122 raise ValueError("incorrect site/theme in template")
123 theme_data = template[1:theme_end]
124 theme_splitted = theme_data.split('/')
125 if len(theme_splitted) == 1:
126 site, theme = "", theme_splitted[0]
127 elif len(theme_splitted) == 2:
128 site, theme = theme_splitted
129 else:
130 raise ValueError("incorrect site/theme in template")
131 template_path = template[theme_end+1:]
132 if not template_path or template_path.startswith("/"):
133 raise ValueError("incorrect template path")
134 elif template.startswith("/"):
135 # this is an absolute path, so we have no site and no theme
136 site = None
137 theme = None
138 template_path = template
139 else:
140 # a default template
141 site = ""
142 theme = C.TEMPLATE_THEME_DEFAULT
143 template_path = template
144
145 if site is not None:
146 site = site.strip()
147 if not site:
148 site = ""
149 elif site in SITE_RESERVED_NAMES:
150 raise ValueError(_("{site} can't be used as site name, "
151 "it's reserved.").format(site=site))
152
153 if theme is not None:
154 theme = theme.strip()
155 if not theme:
156 theme = C.TEMPLATE_THEME_DEFAULT
157 if RE_TPL_RESERVED_CHARS.search(theme):
158 raise ValueError(_("{theme} contain forbidden char. Following chars "
159 "are forbidden: {reserved}").format(
160 theme=theme, reserved=TPL_RESERVED_CHARS))
161
162 return TemplateData(site, theme, template_path)
163
164 @staticmethod
165 def get_sites_and_themes(
166 site: str,
167 theme: str,
168 settings: Optional[dict] = None,
169 ) -> List[Tuple[str, str]]:
170 """Get sites and themes to check for template/file
171
172 Will add default theme and default site in search list when suitable. Settings'
173 `fallback` can be used to modify behaviour: themes in this list will then be used
174 instead of default (it can also be empty list or None, in which case no fallback
175 is used).
176
177 @param site: site requested
178 @param theme: theme requested
179 @return: site and theme couples to check
180 """
181 if settings is None:
182 settings = {}
183 sites_and_themes = [[site, theme]]
184 fallback = settings.get("fallback", [C.TEMPLATE_THEME_DEFAULT])
185 for fb_theme in fallback:
186 if theme != fb_theme:
187 sites_and_themes.append([site, fb_theme])
188 if site:
189 for fb_theme in fallback:
190 sites_and_themes.append(["", fb_theme])
191 return sites_and_themes
192
193 def _get_template_f(self, site, theme, path_elts):
194 """Look for template and return opened file if found
195
196 @param site(unicode): names of site to check
197 (default site will also checked)
198 @param theme(unicode): theme to check (default theme will also be checked)
199 @param path_elts(iterable[str]): elements of template path
200 @return (tuple[(File, None), (str, None)]): a tuple with:
201 - opened template, or None if not found
202 - absolute file path, or None if not found
203 """
204 if site is None:
205 raise exceptions.InternalError(
206 "_get_template_f must not be used with absolute path")
207 settings = self.sites_themes[site][theme]['settings']
208 for site_to_check, theme_to_check in self.get_sites_and_themes(
209 site, theme, settings):
210 try:
211 base_path = self.sites_paths[site_to_check]
212 except KeyError:
213 log.warning(_("Unregistered site requested: {site_to_check}").format(
214 site_to_check=site_to_check))
215 filepath = os.path.join(
216 base_path,
217 C.TEMPLATE_TPL_DIR,
218 theme_to_check,
219 *path_elts
220 )
221 f = utils.open_if_exists(filepath, 'r')
222 if f is not None:
223 return f, filepath
224 return None, None
225
226 def get_source(self, environment, template):
227 """Retrieve source handling site and themes
228
229 If the path is absolute it is used directly if in trusted environment
230 else and exception is raised.
231 if the path is just relative, "default" theme is used.
232 @raise PermissionError: absolute path used in untrusted environment
233 """
234 site, theme, template_path = self.parse_template(template)
235
236 if site is None:
237 # we have an abolute template
238 if theme is not None:
239 raise exceptions.InternalError("We can't have a theme with absolute "
240 "template.")
241 if not self.trusted:
242 log.error(_("Absolute template used while unsecure is disabled, hack "
243 "attempt? Template: {template}").format(template=template))
244 raise exceptions.PermissionError("absolute template is not allowed")
245 filepath = template_path
246 f = utils.open_if_exists(filepath, 'r')
247 else:
248 # relative path, we have to deal with site and theme
249 assert theme and template_path
250 path_elts = split_template_path(template_path)
251 # if we have non default site, we check it first, else we only check default
252 f, filepath = self._get_template_f(site, theme, path_elts)
253
254 if f is None:
255 if (site is not None and path_elts[0] == "error"
256 and os.path.splitext(template_path)[1][1:] in HTML_EXT):
257 # if an HTML error is requested but doesn't exist, we try again
258 # with base error.
259 f, filepath = self._get_template_f(
260 site, theme, ("error", "base.html"))
261 if f is None:
262 raise exceptions.InternalError("error/base.html should exist")
263 else:
264 raise TemplateNotFound(template)
265
266 try:
267 contents = f.read()
268 finally:
269 f.close()
270
271 mtime = os.path.getmtime(filepath)
272
273 def uptodate():
274 try:
275 return os.path.getmtime(filepath) == mtime
276 except OSError:
277 return False
278
279 return contents, filepath, uptodate
280
281
282 class Indexer(object):
283 """Index global to a page"""
284
285 def __init__(self):
286 self._indexes = {}
287
288 def next(self, value):
289 if value not in self._indexes:
290 self._indexes[value] = 0
291 return 0
292 self._indexes[value] += 1
293 return self._indexes[value]
294
295 def current(self, value):
296 return self._indexes.get(value)
297
298
299 class ScriptsHandler(object):
300 def __init__(self, renderer, template_data):
301 self.renderer = renderer
302 self.template_data = template_data
303 self.scripts = [] #  we don't use a set because order may be important
304
305 def include(self, library_name, attribute="defer"):
306 """Mark that a script need to be imported.
307
308 Must be used before base.html is extended, as <script> are generated there.
309 If called several time with the same library, it will be imported once.
310 @param library_name(unicode): name of the library to import
311 @param loading:
312 """
313 if attribute not in ("defer", "async", ""):
314 raise exceptions.DataError(
315 _('Invalid attribute, please use one of "defer", "async" or ""')
316 )
317 if not library_name.endswith(".js"):
318 library_name = library_name + ".js"
319 if (library_name, attribute) not in self.scripts:
320 self.scripts.append((library_name, attribute))
321 return ""
322
323 def generate_scripts(self):
324 """Generate the <script> elements
325
326 @return (unicode): <scripts> HTML tags
327 """
328 scripts = []
329 tpl = "<script src={src} {attribute}></script>"
330 for library, attribute in self.scripts:
331 library_path = self.renderer.get_static_path(self.template_data, library)
332 if library_path is None:
333 log.warning(_("Can't find {libary} javascript library").format(
334 library=library))
335 continue
336 path = self.renderer.get_front_url(library_path)
337 scripts.append(tpl.format(src=quoteattr(path), attribute=attribute))
338 return safe("\n".join(scripts))
339
340
341 class Environment(jinja2.Environment):
342
343 def get_template(self, name, parent=None, globals=None):
344 if name[0] not in ('/', '('):
345 # if name is not an absolute path or a full template name (this happen on
346 # extend or import during rendering), we convert it to a full template name.
347 # This is needed to handle cache correctly when a base template is overriden.
348 # Without that, we could not distinguish something like base/base.html if
349 # it's launched from some_site/some_theme or from [default]/default
350 name = "({site}/{theme}){template}".format(
351 site=self._template_data.site,
352 theme=self._template_data.theme,
353 template=name)
354
355 return super(Environment, self).get_template(name, parent, globals)
356
357
358 class Renderer(object):
359
360 def __init__(self, host, front_url_filter=None, trusted=False, private=False):
361 """
362 @param front_url_filter(callable): filter to retrieve real url of a directory/file
363 The callable will get a two arguments:
364 - a dict with a "template_data" key containing TemplateData instance of
365 current template. Only site and theme should be necessary.
366 - the relative URL of the file to retrieve, relative from theme root
367 None to use default filter which return real path on file
368 Need to be specified for web rendering, to reflect URL seen by end user
369 @param trusted(bool): if True, allow to access absolute path
370 Only set to True if environment is safe (e.g. command line tool)
371 @param private(bool): if True, also load sites from sites_path_private_dict
372 """
373 self.host = host
374 self.trusted = trusted
375 self.sites_paths = {
376 "": os.path.dirname(sat_templates.__file__),
377 }
378 self.sites_themes = {
379 }
380 conf = config.parse_main_conf()
381 public_sites = config.config_get(conf, None, "sites_path_public_dict", {})
382 sites_data = [public_sites]
383 if private:
384 private_sites = config.config_get(conf, None, "sites_path_private_dict", {})
385 sites_data.append(private_sites)
386 for sites in sites_data:
387 normalised = {}
388 for name, path in sites.items():
389 if RE_TPL_RESERVED_CHARS.search(name):
390 log.warning(_("Can't add \"{name}\" site, it contains forbidden "
391 "characters. Forbidden characters are {forbidden}.")
392 .format(name=name, forbidden=TPL_RESERVED_CHARS))
393 continue
394 path = os.path.expanduser(os.path.normpath(path))
395 if not path or not path.startswith("/"):
396 log.warning(_("Can't add \"{name}\" site, it should map to an "
397 "absolute path").format(name=name))
398 continue
399 normalised[name] = path
400 self.sites_paths.update(normalised)
401
402 for site, site_path in self.sites_paths.items():
403 tpl_path = Path(site_path) / C.TEMPLATE_TPL_DIR
404 for p in tpl_path.iterdir():
405 if not p.is_dir():
406 continue
407 log.debug(f"theme found for {site or 'default site'}: {p.name}")
408 theme_data = self.sites_themes.setdefault(site, {})[p.name] = {
409 'path': p,
410 'settings': {}}
411 theme_settings = p / "settings.json"
412 if theme_settings.is_file:
413 try:
414 with theme_settings.open() as f:
415 settings = json.load(f)
416 except Exception as e:
417 log.warning(_(
418 "Can't load theme settings at {path}: {e}").format(
419 path=theme_settings, e=e))
420 else:
421 log.debug(
422 f"found settings for theme {p.name!r} at {theme_settings}")
423 fallback = settings.get("fallback")
424 if fallback is None:
425 settings["fallback"] = []
426 elif isinstance(fallback, str):
427 settings["fallback"] = [fallback]
428 elif not isinstance(fallback, list):
429 raise ValueError(
430 'incorrect type for "fallback" in settings '
431 f'({type(fallback)}) at {theme_settings}: {fallback}'
432 )
433 theme_data['settings'] = settings
434 browser_path = p / BROWSER_DIR
435 if browser_path.is_dir():
436 theme_data['browser_path'] = browser_path
437 browser_meta_path = browser_path / BROWSER_META_FILE
438 if browser_meta_path.is_file():
439 try:
440 with browser_meta_path.open() as f:
441 theme_data['browser_meta'] = json.load(f)
442 except Exception as e:
443 log.error(
444 f"Can't parse browser metadata at {browser_meta_path}: {e}"
445 )
446 continue
447
448 self.env = Environment(
449 loader=TemplateLoader(
450 sites_paths=self.sites_paths,
451 sites_themes=self.sites_themes,
452 trusted=trusted
453 ),
454 autoescape=jinja2.select_autoescape(["html", "xhtml", "xml"]),
455 trim_blocks=True,
456 lstrip_blocks=True,
457 extensions=["jinja2.ext.i18n"],
458 )
459 self.env._template_data = None
460 self._locale_str = C.DEFAULT_LOCALE
461 self._locale = Locale.parse(self._locale_str)
462 self.install_translations()
463
464 # we want to have access to SàT constants in templates
465 self.env.globals["C"] = C
466
467 # custom filters
468 self.env.filters["next_gidx"] = self._next_gidx
469 self.env.filters["cur_gidx"] = self._cur_gidx
470 self.env.filters["date_fmt"] = self._date_fmt
471 self.env.filters["timestamp_to_hour"] = self._timestamp_to_hour
472 self.env.filters["delta_to_human"] = date_utils.delta2human
473 self.env.filters["xmlui_class"] = self._xmlui_class
474 self.env.filters["attr_escape"] = self.attr_escape
475 self.env.filters["item_filter"] = self._item_filter
476 self.env.filters["adv_format"] = self._adv_format
477 self.env.filters["dict_ext"] = self._dict_ext
478 self.env.filters["highlight"] = self.highlight
479 self.env.filters["front_url"] = (self._front_url if front_url_filter is None
480 else front_url_filter)
481 # custom tests
482 self.env.tests["in_the_past"] = self._in_the_past
483 self.icons_path = os.path.join(host.media_dir, "fonts/fontello/svg")
484
485 # policies
486 self.env.policies["ext.i18n.trimmed"] = True
487 self.env.policies["json.dumps_kwargs"] = {
488 "sort_keys": True,
489 # if object can't be serialised, we use None
490 "default": lambda o: o.to_json() if hasattr(o, "to_json") else None
491 }
492
493 def get_front_url(self, template_data, path=None):
494 """Give front URL (i.e. URL seen by end-user) of a path
495
496 @param template_data[TemplateData]: data of current template
497 @param path(unicode, None): relative path of file to get,
498 if set, will remplate template_data.path
499 """
500 return self.env.filters["front_url"]({"template_data": template_data},
501 path or template_data.path)
502
503 def install_translations(self):
504 # TODO: support multi translation
505 # for now, only translations in sat_templates are handled
506 self.translations = {}
507 for site_key, site_path in self.sites_paths.items():
508 site_prefix = "[{}] ".format(site_key) if site_key else ''
509 i18n_dir = os.path.join(site_path, "i18n")
510 for lang_dir in os.listdir(i18n_dir):
511 lang_path = os.path.join(i18n_dir, lang_dir)
512 if not os.path.isdir(lang_path):
513 continue
514 po_path = os.path.join(lang_path, "LC_MESSAGES/sat.mo")
515 try:
516 locale = Locale.parse(lang_dir)
517 with open(po_path, "rb") as f:
518 try:
519 translations = self.translations[locale]
520 except KeyError:
521 self.translations[locale] = support.Translations(f, "sat")
522 else:
523 translations.merge(support.Translations(f, "sat"))
524 except EnvironmentError:
525 log.error(
526 _("Can't find template translation at {path}").format(
527 path=po_path))
528 except UnknownLocaleError as e:
529 log.error(_("{site}Invalid locale name: {msg}").format(
530 site=site_prefix, msg=e))
531 else:
532 log.info(_("{site}loaded {lang} templates translations").format(
533 site = site_prefix,
534 lang=lang_dir))
535
536 default_locale = Locale.parse(self._locale_str)
537 if default_locale not in self.translations:
538 # default locale disable gettext,
539 # so we can use None instead of a Translations instance
540 self.translations[default_locale] = None
541
542 self.env.install_null_translations(True)
543 # we generate a tuple of locales ordered by display name that templates can access
544 # through the "locales" variable
545 self.locales = tuple(sorted(list(self.translations.keys()),
546 key=lambda l: l.language_name.lower()))
547
548
549 def set_locale(self, locale_str):
550 """set current locale
551
552 change current translation locale and self self._locale and self._locale_str
553 """
554 if locale_str == self._locale_str:
555 return
556 if locale_str == "en":
557 # we default to GB English when it's not specified
558 # one of the main reason is to avoid the nonsense U.S. short date format
559 locale_str = "en_GB"
560 try:
561 locale = Locale.parse(locale_str)
562 except ValueError as e:
563 log.warning(_("invalid locale value: {msg}").format(msg=e))
564 locale_str = self._locale_str = C.DEFAULT_LOCALE
565 locale = Locale.parse(locale_str)
566
567 locale_str = str(locale)
568 if locale_str != C.DEFAULT_LOCALE:
569 try:
570 translations = self.translations[locale]
571 except KeyError:
572 log.warning(_("Can't find locale {locale}".format(locale=locale)))
573 locale_str = C.DEFAULT_LOCALE
574 locale = Locale.parse(self._locale_str)
575 else:
576 self.env.install_gettext_translations(translations, True)
577 log.debug(_("Switched to {lang}").format(lang=locale.english_name))
578
579 if locale_str == C.DEFAULT_LOCALE:
580 self.env.install_null_translations(True)
581
582 self._locale = locale
583 self._locale_str = locale_str
584
585 def get_theme_and_root(self, template):
586 """retrieve theme and root dir of a given template
587
588 @param template(unicode): template to parse
589 @return (tuple[unicode, unicode]): theme and absolute path to theme's root dir
590 @raise NotFound: requested site has not been found
591 """
592 # FIXME: check use in jp, and include site
593 site, theme, __ = self.env.loader.parse_template(template)
594 if site is None:
595 # absolute template
596 return "", os.path.dirname(template)
597 try:
598 site_root_dir = self.sites_paths[site]
599 except KeyError:
600 raise exceptions.NotFound
601 return theme, os.path.join(site_root_dir, C.TEMPLATE_TPL_DIR, theme)
602
603 def get_themes_data(self, site_name):
604 try:
605 return self.sites_themes[site_name]
606 except KeyError:
607 raise exceptions.NotFound(f"no theme found for {site_name}")
608
609 def get_static_path(
610 self,
611 template_data: TemplateData,
612 filename: str,
613 settings: Optional[dict]=None
614 ) -> Optional[TemplateData]:
615 """Retrieve path of a static file if it exists with current theme or default
616
617 File will be looked at <site_root_dir>/<theme_dir>/<static_dir>/filename,
618 then <site_root_dir>/<default_theme_dir>/<static_dir>/filename anf finally
619 <default_site>/<default_theme_dir>/<static_dir> (i.e. sat_templates).
620 In case of absolute URL, base dir of template is used as base. For instance if
621 template is an absolute template to "/some/path/template.html", file will be
622 checked at "/some/path/<filename>"
623 @param template_data: data of current template
624 @param filename: name of the file to retrieve
625 @param settings: theme settings, can be used to modify behaviour
626 @return: built template data instance where .path is
627 the relative path to the file, from theme root dir.
628 None if not found.
629 """
630 if template_data.site is None:
631 # we have an absolue path
632 if (not template_data.theme is None
633 or not template_data.path.startswith('/')):
634 raise exceptions.InternalError(
635 "invalid template data, was expecting absolute URL")
636 static_dir = os.path.dirname(template_data.path)
637 file_path = os.path.join(static_dir, filename)
638 if os.path.exists(file_path):
639 return TemplateData(site=None, theme=None, path=file_path)
640 else:
641 return None
642
643 sites_and_themes = TemplateLoader.get_sites_and_themes(template_data.site,
644 template_data.theme,
645 settings)
646 for site, theme in sites_and_themes:
647 site_root_dir = self.sites_paths[site]
648 relative_path = os.path.join(C.TEMPLATE_STATIC_DIR, filename)
649 absolute_path = os.path.join(site_root_dir, C.TEMPLATE_TPL_DIR,
650 theme, relative_path)
651 if os.path.exists(absolute_path):
652 return TemplateData(site=site, theme=theme, path=relative_path)
653
654 return None
655
656 def _append_css_paths(
657 self,
658 template_data: TemplateData,
659 css_files: list,
660 css_files_noscript: list,
661 name_root: str,
662 settings: dict
663
664 ) -> None:
665 """Append found css to css_files and css_files_noscript
666
667 @param css_files: list to fill of relative path to found css file
668 @param css_files_noscript: list to fill of relative path to found css file
669 with "_noscript" suffix
670 """
671 name = name_root + ".css"
672 css_path = self.get_static_path(template_data, name, settings)
673 if css_path is not None:
674 css_files.append(self.get_front_url(css_path))
675 noscript_name = name_root + "_noscript.css"
676 noscript_path = self.get_static_path(
677 template_data, noscript_name, settings)
678 if noscript_path is not None:
679 css_files_noscript.append(self.get_front_url(noscript_path))
680
681 def get_css_files(self, template_data):
682 """Retrieve CSS files to use according template_data
683
684 For each element of the path, a .css file is looked for in /static, and returned
685 if it exists.
686 Previous element are kept by replacing '/' with '_'.
687 styles_extra.css, styles.css, highlight.css and fonts.css are always used if they
688 exist.
689 For each found file, if a file with the same name and "_noscript" suffix exists,
690 it will be returned is second part of resulting tuple.
691 For instance, if template_data is (some_site, some_theme, blog/articles.html),
692 following files are returned, each time trying [some_site root] first,
693 then default site (i.e. sat_templates) root:
694 - some_theme/static/styles.css is returned if it exists
695 else default/static/styles.css
696 - some_theme/static/blog.css is returned if it exists
697 else default/static/blog.css (if it exists too)
698 - some_theme/static/blog_articles.css is returned if it exists
699 else default/static/blog_articles.css (if it exists too)
700 and for each found files, if same file with _noscript suffix exists, it is put
701 in noscript list (for instance (some_theme/static/styles_noscript.css)).
702 The behaviour may be changed with theme settings: if "fallback" is set, specified
703 themes will be checked instead of default. The theme will be checked in given
704 order, and "fallback" may be None or empty list to not check anything.
705 @param template_data(TemplateData): data of the current template
706 @return (tuple[list[unicode], list[unicode]]): a tuple with:
707 - front URLs of CSS files to use
708 - front URLs of CSS files to use when scripts are not enabled
709 """
710 # TODO: some caching would be nice
711 css_files = []
712 css_files_noscript = []
713 path_elems = template_data.path.split('/')
714 path_elems[-1] = os.path.splitext(path_elems[-1])[0]
715 site = template_data.site
716 if site is None:
717 # absolute path
718 settings = {}
719 else:
720 settings = self.sites_themes[site][template_data.theme]['settings']
721
722 css_path = self.get_static_path(template_data, 'fonts.css', settings)
723 if css_path is not None:
724 css_files.append(self.get_front_url(css_path))
725
726 for name_root in ('styles', 'styles_extra', 'highlight'):
727 self._append_css_paths(
728 template_data, css_files, css_files_noscript, name_root, settings)
729
730 for idx in range(len(path_elems)):
731 name_root = "_".join(path_elems[:idx+1])
732 self._append_css_paths(
733 template_data, css_files, css_files_noscript, name_root, settings)
734
735 return css_files, css_files_noscript
736
737 ## custom filters ##
738
739 @contextfilter
740 def _front_url(self, ctx, relative_url):
741 """Get front URL (URL seen by end-user) from a relative URL
742
743 This default method return absolute full path
744 """
745 template_data = ctx['template_data']
746 if template_data.site is None:
747 assert template_data.theme is None
748 assert template_data.path.startswith("/")
749 return os.path.join(os.path.dirname(template_data.path, relative_url))
750
751 site_root_dir = self.sites_paths[template_data.site]
752 return os.path.join(site_root_dir, C.TEMPLATE_TPL_DIR, template_data.theme,
753 relative_url)
754
755 @contextfilter
756 def _next_gidx(self, ctx, value):
757 """Use next current global index as suffix"""
758 next_ = ctx["gidx"].next(value)
759 return value if next_ == 0 else "{}_{}".format(value, next_)
760
761 @contextfilter
762 def _cur_gidx(self, ctx, value):
763 """Use current current global index as suffix"""
764 current = ctx["gidx"].current(value)
765 return value if not current else "{}_{}".format(value, current)
766
767 def _date_fmt(
768 self,
769 timestamp: Union[int, float],
770 fmt: str = "short",
771 date_only: bool = False,
772 auto_limit: int = 7,
773 auto_old_fmt: str = "short",
774 auto_new_fmt: str = "relative",
775 tz_name: Optional[str] = None
776 ) -> str:
777 if is_undefined(fmt):
778 fmt = "short"
779
780 try:
781 return date_utils.date_fmt(
782 timestamp, fmt, date_only, auto_limit, auto_old_fmt,
783 auto_new_fmt, locale_str = self._locale_str,
784 tz_info=tz_name or date_utils.TZ_UTC
785 )
786 except Exception as e:
787 log.warning(_("Can't parse date: {msg}").format(msg=e))
788 return str(timestamp)
789
790 def _timestamp_to_hour(self, timestamp: float) -> int:
791 """Get hour of day corresponding to a timestamp"""
792 dt = datetime.fromtimestamp(timestamp)
793 return dt.hour
794
795 def attr_escape(self, text):
796 """escape a text to a value usable as an attribute
797
798 remove spaces, and put in lower case
799 """
800 return RE_ATTR_ESCAPE.sub("_", text.strip().lower())[:50]
801
802 def _xmlui_class(self, xmlui_item, fields):
803 """return classes computed from XMLUI fields name
804
805 will return a string with a series of escaped {name}_{value} separated by spaces.
806 @param xmlui_item(xmlui.XMLUIPanel): XMLUI containing the widgets to use
807 @param fields(iterable(unicode)): names of the widgets to use
808 @return (unicode, None): computer string to use as class attribute value
809 None if no field was specified
810 """
811 classes = []
812 for name in fields:
813 escaped_name = self.attr_escape(name)
814 try:
815 for value in xmlui_item.widgets[name].values:
816 classes.append(escaped_name + "_" + self.attr_escape(value))
817 except KeyError:
818 log.debug(
819 _('ignoring field "{name}": it doesn\'t exists').format(name=name)
820 )
821 continue
822 return " ".join(classes) or None
823
824 @contextfilter
825 def _item_filter(self, ctx, item, filters):
826 """return item's value, filtered if suitable
827
828 @param item(object): item to filter
829 value must have name and value attributes,
830 mostly used for XMLUI items
831 @param filters(dict[unicode, (callable, dict, None)]): map of name => filter
832 if filter is None, return the value unchanged
833 if filter is a callable, apply it
834 if filter is a dict, it can have following keys:
835 - filters: iterable of filters to apply
836 - filters_args: kwargs of filters in the same order as filters (use empty
837 dict if needed)
838 - template: template to format where {value} is the filtered value
839 """
840 value = item.value
841 filter_ = filters.get(item.name, None)
842 if filter_ is None:
843 return value
844 elif isinstance(filter_, dict):
845 filters_args = filter_.get("filters_args")
846 for idx, f_name in enumerate(filter_.get("filters", [])):
847 kwargs = filters_args[idx] if filters_args is not None else {}
848 filter_func = self.env.filters[f_name]
849 try:
850 eval_context_filter = filter_func.evalcontextfilter
851 except AttributeError:
852 eval_context_filter = False
853
854 if eval_context_filter:
855 value = filter_func(ctx.eval_ctx, value, **kwargs)
856 else:
857 value = filter_func(value, **kwargs)
858 template = filter_.get("template")
859 if template:
860 # format will return a string, so we need to check first
861 # if the value is safe or not, and re-mark it after formatting
862 is_safe = isinstance(value, safe)
863 value = template.format(value=value)
864 if is_safe:
865 value = safe(value)
866 return value
867
868 def _adv_format(self, value, template, **kwargs):
869 """Advancer formatter
870
871 like format() method, but take care or special values like None
872 @param value(unicode): value to format
873 @param template(None, unicode): template to use with format() method.
874 It will be formatted using value=value and **kwargs
875 None to return value unchanged
876 @return (unicode): formatted value
877 """
878 if template is None:
879 return value
880 #  jinja use string when no special char is used, so we have to convert to unicode
881 return str(template).format(value=value, **kwargs)
882
883 def _dict_ext(self, source_dict, extra_dict, key=None):
884 """extend source_dict with extra dict and return the result
885
886 @param source_dict(dict): dictionary to extend
887 @param extra_dict(dict, None): dictionary to use to extend first one
888 None to return source_dict unmodified
889 @param key(unicode, None): if specified extra_dict[key] will be used
890 if it doesn't exists, a copy of unmodified source_dict is returned
891 @return (dict): resulting dictionary
892 """
893 if extra_dict is None:
894 return source_dict
895 if key is not None:
896 extra_dict = extra_dict.get(key, {})
897 ret = source_dict.copy()
898 ret.update(extra_dict)
899 return ret
900
901 def highlight(self, code, lexer_name=None, lexer_opts=None, html_fmt_opts=None):
902 """Do syntax highlighting on code
903
904 Under the hood, pygments is used, check its documentation for options possible
905 values.
906 @param code(unicode): code or markup to highlight
907 @param lexer_name(unicode, None): name of the lexer to use
908 None to autodetect it
909 @param html_fmt_opts(dict, None): kword arguments to use for HtmlFormatter
910 @return (unicode): HTML markup with highlight classes
911 """
912 if lexer_opts is None:
913 lexer_opts = {}
914 if html_fmt_opts is None:
915 html_fmt_opts = {}
916 if lexer_name is None:
917 lexer = lexers.guess_lexer(code, **lexer_opts)
918 else:
919 lexer = lexers.get_lexer_by_name(lexer_name, **lexer_opts)
920 formatter = formatters.HtmlFormatter(**html_fmt_opts)
921 return safe(pygments.highlight(code, lexer, formatter))
922
923 ## custom tests ##
924
925 def _in_the_past(self, timestamp):
926 """check if a date is in the past
927
928 @param timestamp(unicode, int): unix time
929 @return (bool): True if date is in the past
930 """
931 return time.time() > int(timestamp)
932
933 ## template methods ##
934
935 def _icon_defs(self, *names):
936 """Define svg icons which will be used in the template.
937
938 Their name is used as id
939 """
940 svg_elt = etree.Element(
941 "svg",
942 nsmap={None: "http://www.w3.org/2000/svg"},
943 width="0",
944 height="0",
945 style="display: block",
946 )
947 defs_elt = etree.SubElement(svg_elt, "defs")
948 for name in names:
949 path = os.path.join(self.icons_path, name + ".svg")
950 icon_svg_elt = etree.parse(path).getroot()
951 # we use icon name as id, so we can retrieve them easily
952 icon_svg_elt.set("id", name)
953 if not icon_svg_elt.tag == "{http://www.w3.org/2000/svg}svg":
954 raise exceptions.DataError("invalid SVG element")
955 defs_elt.append(icon_svg_elt)
956 return safe(etree.tostring(svg_elt, encoding="unicode"))
957
958 def _icon_use(self, name, cls=""):
959 return safe('<svg class="svg-icon{cls}" xmlns="http://www.w3.org/2000/svg" '
960 'viewBox="0 0 100 100">\n'
961 ' <use href="#{name}"/>'
962 '</svg>\n'.format(name=name, cls=(" " + cls) if cls else ""))
963
964 def _icon_from_client(self, client):
965 """Get icon name to represent a disco client"""
966 if client is None:
967 return 'desktop'
968 elif 'pc' in client:
969 return 'desktop'
970 elif 'phone' in client:
971 return 'mobile'
972 elif 'web' in client:
973 return 'globe'
974 elif 'console' in client:
975 return 'terminal'
976 else:
977 return 'desktop'
978
979 def render(self, template, site=None, theme=None, locale=C.DEFAULT_LOCALE,
980 media_path="", css_files=None, css_inline=False, **kwargs):
981 """Render a template
982
983 @param template(unicode): template to render (e.g. blog/articles.html)
984 @param site(unicode): site name
985 None or empty string for defaut site (i.e. SàT templates)
986 @param theme(unicode): template theme
987 @param media_path(unicode): prefix of the SàT media path/URL to use for
988 template root. Must end with a u'/'
989 @param css_files(list[unicode],None): CSS files to use
990 CSS files must be in static dir of the template
991 use None for automatic selection of CSS files based on template category
992 None is recommended. General static/style.css and theme file name will be
993 used.
994 @param css_inline(bool): if True, CSS will be embedded in the HTML page
995 @param **kwargs: variable to transmit to the template
996 """
997 if not template:
998 raise ValueError("template can't be empty")
999 if site is not None or theme is not None:
1000 # user wants to set site and/or theme, so we add it to the template path
1001 if site is None:
1002 site = ''
1003 if theme is None:
1004 theme = C.TEMPLATE_THEME_DEFAULT
1005 if template[0] == "(":
1006 raise ValueError(
1007 "you can't specify site or theme in template path and in argument "
1008 "at the same time"
1009 )
1010
1011 template_data = TemplateData(site, theme, template)
1012 template = "({site}/{theme}){template}".format(
1013 site=site, theme=theme, template=template)
1014 else:
1015 template_data = self.env.loader.parse_template(template)
1016
1017 # we need to save template_data in environment, to load right templates when they
1018 # are referenced from other templates (e.g. import)
1019 # FIXME: this trick will not work anymore if we use async templates (it works
1020 # here because we know that the rendering will be blocking until we unset
1021 # _template_data)
1022 self.env._template_data = template_data
1023
1024 template_source = self.env.get_template(template)
1025
1026 if css_files is None:
1027 css_files, css_files_noscript = self.get_css_files(template_data)
1028 else:
1029 css_files_noscript = []
1030
1031 kwargs["icon_defs"] = self._icon_defs
1032 kwargs["icon"] = self._icon_use
1033 kwargs["icon_from_client"] = self._icon_from_client
1034
1035 if css_inline:
1036 css_contents = []
1037 for files, suffix in ((css_files, ""),
1038 (css_files_noscript, "_noscript")):
1039 site_root_dir = self.sites_paths[template_data.site]
1040 for css_file in files:
1041 css_file_path = os.path.join(site_root_dir, css_file)
1042 with open(css_file_path) as f:
1043 css_contents.append(f.read())
1044 if css_contents:
1045 kwargs["css_content" + suffix] = "\n".join(css_contents)
1046
1047 scripts_handler = ScriptsHandler(self, template_data)
1048 self.set_locale(locale)
1049
1050 # XXX: theme used in template arguments is the requested theme, which may differ
1051 # from actual theme if the template doesn't exist in the requested theme.
1052 rendered = template_source.render(
1053 template_data=template_data,
1054 media_path=media_path,
1055 css_files=css_files,
1056 css_files_noscript=css_files_noscript,
1057 locale=self._locale,
1058 locales=self.locales,
1059 gidx=Indexer(),
1060 script=scripts_handler,
1061 **kwargs
1062 )
1063 self.env._template_data = None
1064 return rendered