Mercurial > libervia-backend
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 |