view src/tools/common/template.py @ 2169:f472179305a1

tools(templates): workflow improvments: - template theme can be specified in parenthesis: (some_theme)path/to/template.html. Withtout parenthesis, "default" is used - static content are supposed to be in [theme]/static, error pages in [theme]/error/[err_code].html - default page are used in some case (2 for now): if error page with specified code doesn't exists, a base page is used, and if a page doesn't exist for a theme, the same one for default theme is looked for - CSS files are automatically found for HTML pages - CSS files can be split, the'll be added in the template according to the page requested. - theme CSS file is looked for, and if not found the default theme equivalent is looked for. - each element of a path can be associated to a CSS file, and styles.css is always there. For instance if blog/articles.html is requested, the following CSS can be included: "styles.css", "blog.css", "blog_article.css". They all must be in /static - if the automatic finding of CSS files is not wanted, css_files arguments can be used instead, with full relative path (i.e. including theme) - CSS files can be merged and included inline with css_inline argument - root_path can be specified, it will be used as a prefix for static files - requested theme (which may differ from actual theme, e.g. if the template is not found and default one is used instead) is available in template with "theme" variable - added getThemeAndRoot method to retrieve theme and theme root path from template
author Goffi <goffi@goffi.org>
date Sun, 05 Mar 2017 23:41:10 +0100
parents 5734b0994cf0
children e09048cb7595
line wrap: on
line source

#!/usr/bin/env python2
# -*- coding: utf-8 -*-

# SAT: a jabber client
# Copyright (C) 2009-2016 Jérôme Poisson (goffi@goffi.org)

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.

# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.

# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

""" template generation """

from sat.core.constants import Const as C
from sat.core import exceptions
from sat.core.log import getLogger
log = getLogger(__name__)
import os.path
try:
    import sat_templates
except ImportError:
    raise exceptions.MissingModule(u'sat_templates module is not available, please install it or check your path to use template engine')
else:
    sat_templates  # to avoid pyflakes warning

try:
    import jinja2
except:
    raise exceptions.MissingModule(u'Missing module jinja2, please install it from http://jinja.pocoo.org or with pip install jinja2')

HTML_EXT = ('html', 'xhtml')
# TODO: handle external path (an additional search path for templates should be settable by user
# TODO: handle absolute URL (should be used for trusted use cases) only (e.g. jp) for security reason


class TemplateLoader(jinja2.FileSystemLoader):

    def __init__(self):
        searchpath = os.path.dirname(sat_templates.__file__)
        super(TemplateLoader, self).__init__(searchpath, followlinks=True)

    def parse_template(self, template):
        """parse template path and return theme and relative URL

        @param template_path(unicode): path to template with parenthesis syntax
        @return (tuple[(unicode,None),unicode]): theme and template_path
            theme can be None if relative path is used
            relative path is the path from search path with theme specified
            e.g. default/blog/articles.html
        """
        if template.startswith(u'('):
            try:
                theme_end = template.index(u')')
            except IndexError:
                raise ValueError(u"incorrect theme in template")
            theme = template[1:theme_end]
            template = template[theme_end+1:]
            if not template or template.startswith(u'/'):
                raise ValueError(u"incorrect path after template name")
            template = os.path.join(theme, template)
        elif template.startswith(u'/'):
            # absolute path means no template
            theme = None
            raise NotImplementedError(u'absolute path is not implemented yet')
        else:
            theme = C.TEMPLATE_THEME_DEFAULT
            template = os.path.join(theme, template)
        return theme, template

    def get_default_template(self, theme, template_path):
        """return default template path

        @param theme(unicode): theme used
        @param template_path(unicode): path to the not found template
        @return (unicode, None): default path or None if there is not
        """
        ext = os.path.splitext(template_path)[1][1:]
        path_elems = template_path.split(u'/')
        if ext in HTML_EXT:
            if path_elems[1] == u'error':
                # if an inexisting error page is requested, we return base page
                default_path = os.path.join(theme, u'error/base.html')
                return default_path
        if theme != C.TEMPLATE_THEME_DEFAULT:
            # if template doesn't exists for this theme, we try with default
            return os.path.join(C.TEMPLATE_THEME_DEFAULT, path_elems[1:])

    def get_source(self, environment, template):
        """relative path to template dir, with special theme handling

        if the path is just relative, "default" theme is used.
        The theme can be specified in parenthesis just before the path
        e.g.: (some_theme)path/to/template.html
        """
        theme, template_path = self.parse_template(template)
        try:
            return super(TemplateLoader, self).get_source(environment, template_path)
        except jinja2.exceptions.TemplateNotFound as e:
            # in some special cases, a defaut template is returned if nothing is found
            if theme is not None:
                default_path = self.get_default_template(theme, template_path)
                if default_path is not None:
                    return super(TemplateLoader, self).get_source(environment, default_path)
            # if no default template is found, we re-raise the error
            raise e


class Renderer(object):

    def __init__(self, host):
        self.host = host
        self.base_dir = os.path.dirname(sat_templates.__file__)  # FIXME: should be modified if we handle use extra dirs
        self.env = jinja2.Environment(
            loader=TemplateLoader(),
            autoescape=jinja2.select_autoescape(['html', 'xhtml', 'xml']),
            trim_blocks=True,
            lstrip_blocks=True,
            )
        # we want to have access to SàT constants in templates
        self.env.globals[u'C'] = C

    def getThemeAndRoot(self, template):
        """retrieve theme and root dir of a given tempalte

        @param template(unicode): template to parse
        @return (tuple[unicode, unicode]): theme and absolute path to theme's root dir
        """
        theme, dummy = self.env.loader.parse_template(template)
        return theme, os.path.join(self.base_dir, theme)

    def _appendCSSIfExists(self, css_files, template_root_dir, theme, name, is_default):
        """append CSS file to list if it exists, else try with default theme

        CSS file will be looked at [theme]/static/[name].css, and then default
        if not found.
        @param css_files(list): list of CSS file to be completed
        @param template_root_dir(unicode): absolute path to template root used
        @param theme(unicode): name of the template theme used
        @param name(unicode): name of the CSS file to look for
        @param is_default(bool): True if theme is the default theme
        """
        css_path = os.path.join(theme, C.TEMPLATE_STATIC_DIR, name + '.css')
        if os.path.exists(os.path.join(template_root_dir, css_path)):
            css_files.append(css_path)
        elif not is_default:
            css_path = os.path.join(C.TEMPLATE_THEME_DEFAULT, C.TEMPLATE_STATIC_DIR, name + '.css')
            if os.path.exists(os.path.join(template_root_dir, css_path)):
                css_files.append(css_path)

    def getCSSFiles(self, template_path, template_root_dir):
        """retrieve CSS files to use according to theme and template path

        for each element of the path, a .css file is looked for in /static, and returned if it exists.
        previous element are kept by replacing '/' with '_', and styles.css is always returned.
        For instance, if template_path is some_theme/blog/articles.html:
            some_theme/static/styles.css is returned if it exists else default/static/styles.css
            some_theme/static/blog.css is returned if it exists else default/static/blog.css (if it exists too)
            some_theme/static/blog_articles.css is returned if it exists else default/static/blog_articles.css (if it exists too)
        @param template_path(unicode): relative path to template file (e.g. some_theme/blog/articles.html)
        @param template_root_dir(unicode): absolute path of the theme root dir used
        @return list[unicode]: relative path to CSS files to use
        """
        # TODO: some caching would be nice
        css_files = []
        path_elems = template_path.split(u'/')
        theme = path_elems.pop(0)
        is_default = theme == C.TEMPLATE_THEME_DEFAULT
        self._appendCSSIfExists(css_files, template_root_dir, theme, u'styles', is_default)

        for idx, path in enumerate(path_elems):
            self._appendCSSIfExists(css_files, template_root_dir, theme, u'_'.join(path_elems[:idx+1]), is_default)

        return css_files

    def render(self, template, theme=None, root_path=u'', css_files=None, css_inline=False, **kwargs):
        """render a template

        @param template(unicode): template to render (e.g. blog/articles.html)
        @param theme(unicode): template theme
        @param root_path(unicode): prefix of the path/URL to use for template root
            must end with a u'/'
        @param css_files(list[unicode],None): CSS files to used
            CSS files must be in static dir of the template
            use None for automatic selection of CSS files based on template category
            None is recommended. General static/style.css and theme file name will be used.
        @param css_inline(bool): if True, CSS will be embedded in the HTML page
        @param **kwargs: variable to transmit to the template
        """
        if not template:
            raise ValueError(u"template can't be empty")
        if theme is not None:
            # use want to set a theme, we add it to the template path
            if template[0] == u'(':
                raise ValueError(u"you can't specify theme in template path and in argument at the same time")
            elif template[0] == u'/':
                raise ValueError(u"you can't specify theme with absolute paths")
            template= u'(' + theme + u')' + template
        else:
            theme, dummy = self.env.loader.parse_template(template)

        template_source = self.env.get_template(template)
        template_root_dir = os.path.normpath(self.base_dir)  # FIXME: should be modified if we handle use extra dirs
        # XXX: template_path may have a different theme as first element than theme if a default page is used
        template_path = template_source.filename[len(template_root_dir)+1:]

        if css_files is None:
            css_files = self.getCSSFiles(template_path, template_root_dir)

        if css_inline:
            css_contents = []
            for css_file in css_files:
                css_file_path = os.path.join(template_root_dir, css_file)
                with open(css_file_path) as f:
                    css_contents.append(f.read())
            if css_contents:
                kwargs['css_content'] = '\n'.join(css_contents)
        # XXX: theme used in template arguments is the requested theme, which may differ from actual theme
        #      if the template doesn't exist in the requested theme.
        return template_source.render(theme=theme, root_path=root_path, css_files=css_files, **kwargs)