diff --git a/openerp/addons/base/__openerp__.py b/openerp/addons/base/__openerp__.py index 3d0d3e4dda4..d38ac7ceed1 100644 --- a/openerp/addons/base/__openerp__.py +++ b/openerp/addons/base/__openerp__.py @@ -99,9 +99,5 @@ The kernel of OpenERP, needed for all installation. ], 'installable': True, 'auto_install': True, - 'css': ['static/src/css/modules.css'], - 'js': [ - 'static/src/js/apps.js', - ], } # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: diff --git a/openerp/addons/base/ir/ir_qweb.py b/openerp/addons/base/ir/ir_qweb.py index 4608f63bd49..798d0e5fe24 100644 --- a/openerp/addons/base/ir/ir_qweb.py +++ b/openerp/addons/base/ir/ir_qweb.py @@ -2,19 +2,27 @@ import collections import cStringIO import datetime +import hashlib import json import logging import math +import os import re import sys import xml # FIXME use lxml and etree +import itertools +import lxml.html +from urlparse import urlparse import babel import babel.dates -import werkzeug.utils +import werkzeug from PIL import Image +import openerp.http import openerp.tools +import openerp.tools.func +import openerp.tools.lru from openerp.tools.safe_eval import safe_eval as eval from openerp.osv import osv, orm, fields from openerp.tools.translate import _ @@ -396,6 +404,22 @@ class QWeb(orm.AbstractModel): pass return self.render(cr, uid, template, d) + def render_tag_call_assets(self, element, template_attributes, generated_attributes, qwebcontext): + """ This special 't-call' tag can be used in order to aggregate/minify javascript and css assets""" + name = template_attributes['call-assets'] + + # Backward compatibility hack for manifest usage + qwebcontext['manifest_list'] = openerp.addons.web.controllers.main.manifest_list + + d = qwebcontext.copy() + d.context['inherit_branding'] = False + content = self.render_tag_call( + element, {'call': name}, generated_attributes, d) + if qwebcontext.get('debug'): + return content + bundle = AssetsBundle(name, html=content) + return bundle.to_html() + def render_tag_set(self, element, template_attributes, generated_attributes, qwebcontext): if "value" in template_attributes: qwebcontext[template_attributes["set"]] = self.eval_object(template_attributes["value"], qwebcontext) @@ -821,7 +845,6 @@ class RelativeDatetimeConverter(osv.AbstractModel): return babel.dates.format_timedelta( value - reference, add_direction=True, locale=locale) - class Contact(orm.AbstractModel): _name = 'ir.qweb.field.contact' _inherit = 'ir.qweb.field.many2one' @@ -951,4 +974,233 @@ def get_field_type(column, options): """ return options.get('widget', column._type) +class AssetsBundle(object): + cache = openerp.tools.lru.LRU(32) + rx_css_import = re.compile("(@import[^;{]+;?)", re.M) + + def __init__(self, xmlid, html=None, debug=False): + self.debug = debug + self.xmlid = xmlid + self.javascripts = [] + self.stylesheets = [] + self.remains = [] + self._checksum = None + if html: + self.parse(html) + + def parse(self, html): + fragments = lxml.html.fragments_fromstring(html) + for el in fragments: + if isinstance(el, basestring): + self.remains.append(el) + elif isinstance(el, lxml.html.HtmlElement): + src = el.get('src') + href = el.get('href') + if el.tag == 'style': + self.stylesheets.append(StylesheetAsset(source=el.text)) + elif el.tag == 'link' and el.get('rel') == 'stylesheet' and self.can_aggregate(href): + self.stylesheets.append(StylesheetAsset(url=href)) + elif el.tag == 'script' and not src: + self.javascripts.append(JavascriptAsset(source=el.text)) + elif el.tag == 'script' and self.can_aggregate(src): + self.javascripts.append(JavascriptAsset(url=src)) + else: + self.remains.append(lxml.html.tostring(el)) + else: + try: + self.remains.append(lxml.html.tostring(el)) + except Exception: + # notYETimplementederror + raise NotImplementedError + + def can_aggregate(self, url): + return not urlparse(url).netloc and not url.startswith(('/web/css', '/web/js')) + + def to_html(self, sep='\n'): + response = [] + if self.stylesheets: + response.append('' % self.xmlid) + if self.javascripts: + response.append('' % self.xmlid) + response.extend(self.remains) + + return sep.join(response) + + @openerp.tools.func.lazy_property + def last_modified(self): + return max(itertools.chain( + (asset.last_modified for asset in self.javascripts), + (asset.last_modified for asset in self.stylesheets), + [datetime.datetime(1970, 1, 1)], + )) + + @openerp.tools.func.lazy_property + def checksum(self): + checksum = hashlib.new('sha1') + for asset in itertools.chain(self.javascripts, self.stylesheets): + checksum.update(asset.content.encode("utf-8")) + return checksum.hexdigest() + + def js(self): + key = 'js_' + self.checksum + if key not in self.cache: + content =';\n'.join(asset.minify() for asset in self.javascripts) + self.cache[key] = content + if self.debug: + return "/*\n%s\n*/\n" % '\n'.join( + [asset.filename for asset in self.javascripts if asset.filename]) + self.cache[key] + return self.cache[key] + + def css(self): + key = 'css_' + self.checksum + if key not in self.cache: + content = '\n'.join(asset.minify() for asset in self.stylesheets) + # move up all @import rules to the top + matches = [] + def push(matchobj): + matches.append(matchobj.group(0)) + return '' + + content = re.sub(self.rx_css_import, push, content) + + matches.append(content) + content = u'\n'.join(matches) + self.cache[key] = content + if self.debug: + return "/*\n%s\n*/\n" % '\n'.join( + [asset.filename for asset in self.javascripts if asset.filename]) + self.cache[key] + return self.cache[key] + +class WebAsset(object): + def __init__(self, source=None, url=None): + self.source = source + self.url = url + self._filename = None + self._content = None + + @property + def filename(self): + if self._filename is None and self.url: + module = filter(None, self.url.split('/'))[0] + try: + mpath = openerp.http.addons_manifest[module]['addons_path'] + except Exception: + raise KeyError("Could not find asset '%s' for '%s' addon" % (self.url, module)) + self._filename = mpath + self.url.replace('/', os.path.sep) + return self._filename + + @property + def content(self): + if self._content is None: + self._content = self.get_content() + return self._content + + def get_content(self): + if self.source: + return self.source + + with open(self.filename, 'rb') as fp: + return fp.read().decode('utf-8') + + def minify(self): + return self.content + + @property + def last_modified(self): + if self.source: + # TODO: return last_update of bundle's ir.ui.view + return datetime.datetime(1970, 1, 1) + return datetime.datetime.fromtimestamp(os.path.getmtime(self.filename)) + +class JavascriptAsset(WebAsset): + def minify(self): + return rjsmin(self.content) + +class StylesheetAsset(WebAsset): + rx_import = re.compile(r"""@import\s+('|")(?!'|"|/|https?://)""", re.U) + rx_url = re.compile(r"""url\s*\(\s*('|"|)(?!'|"|/|https?://|data:)""", re.U) + rx_comments = re.compile(r"""/\*.*\*/""", re.S) + rx_sourceMap = re.compile(r'(/\*# sourceMappingURL=.*)', re.U) + + def _get_content(self): + if self.source: + return self.source + + with open(self.filename, 'rb') as fp: + firstline = fp.readline() + m = re.match(r'@charset "([^"]+)";', firstline) + if m: + encoding = m.group(1) + else: + encoding = "utf-8" + # "reinject" first line as it's not @charset + fp.seek(0) + + return fp.read().decode(encoding) + + def get_content(self): + content = self._get_content() + if self.url: + web_dir = os.path.dirname(self.url) + + content = self.rx_import.sub( + r"""@import \1%s/""" % (web_dir,), + content, + ) + + content = self.rx_url.sub( + r"url(\1%s/" % (web_dir,), + content, + ) + return content + + def minify(self): + # remove existing sourcemaps, make no sense after re-mini + return self.rx_sourceMap.sub('', self.content) + # return self.rx_comments.sub('', self.content) + +def rjsmin(script): + """ Minify js with a clever regex. + Taken from http://opensource.perlig.de/rjsmin + Apache License, Version 2.0 """ + def subber(match): + """ Substitution callback """ + groups = match.groups() + return ( + groups[0] or + groups[1] or + groups[2] or + groups[3] or + (groups[4] and '\n') or + (groups[5] and ' ') or + (groups[6] and ' ') or + (groups[7] and ' ') or + '' + ) + + result = re.sub( + r'([^\047"/\000-\040]+)|((?:(?:\047[^\047\\\r\n]*(?:\\(?:[^\r\n]|\r?' + r'\n|\r)[^\047\\\r\n]*)*\047)|(?:"[^"\\\r\n]*(?:\\(?:[^\r\n]|\r?\n|' + r'\r)[^"\\\r\n]*)*"))[^\047"/\000-\040]*)|(?:(?<=[(,=:\[!&|?{};\r\n]' + r')(?:[\000-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/' + r'))*((?:/(?![\r\n/*])[^/\\\[\r\n]*(?:(?:\\[^\r\n]|(?:\[[^\\\]\r\n]*' + r'(?:\\[^\r\n][^\\\]\r\n]*)*\]))[^/\\\[\r\n]*)*/)[^\047"/\000-\040]*' + r'))|(?:(?<=[\000-#%-,./:-@\[-^`{-~-]return)(?:[\000-\011\013\014\01' + r'6-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))*((?:/(?![\r\n/*])[^/' + r'\\\[\r\n]*(?:(?:\\[^\r\n]|(?:\[[^\\\]\r\n]*(?:\\[^\r\n][^\\\]\r\n]' + r'*)*\]))[^/\\\[\r\n]*)*/)[^\047"/\000-\040]*))|(?<=[^\000-!#%&(*,./' + r':-@\[\\^`{|~])(?:[\000-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/' + r'*][^*]*\*+)*/))*(?:((?:(?://[^\r\n]*)?[\r\n]))(?:[\000-\011\013\01' + r'4\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))*)+(?=[^\000-\040"#' + r'%-\047)*,./:-@\\-^`|-~])|(?<=[^\000-#%-,./:-@\[-^`{-~-])((?:[\000-' + r'\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)))+(?=[^' + r'\000-#%-,./:-@\[-^`{-~-])|(?<=\+)((?:[\000-\011\013\014\016-\040]|' + r'(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)))+(?=\+)|(?<=-)((?:[\000-\011\0' + r'13\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)))+(?=-)|(?:[\0' + r'00-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))+|(?:' + r'(?:(?://[^\r\n]*)?[\r\n])(?:[\000-\011\013\014\016-\040]|(?:/\*[^*' + r']*\*+(?:[^/*][^*]*\*+)*/))*)+', subber, '\n%s\n' % script + ).strip() + return result + # vim:et: diff --git a/openerp/addons/base/ir/ir_ui_view.py b/openerp/addons/base/ir/ir_ui_view.py index 697afd1e28f..ea2c9d6ecbe 100644 --- a/openerp/addons/base/ir/ir_ui_view.py +++ b/openerp/addons/base/ir/ir_ui_view.py @@ -868,7 +868,8 @@ class view(osv.osv): values = dict() qcontext = dict( keep_query=keep_query, - request=request, + request=request, # might be unbound if we're not in an httprequest context + debug=request.debug if request else False, json=simplejson, quote_plus=werkzeug.url_quote_plus, ) diff --git a/openerp/http.py b/openerp/http.py index 83bcf81575f..6258816cd63 100644 --- a/openerp/http.py +++ b/openerp/http.py @@ -559,7 +559,7 @@ class HttpRequest(WebRequest): response.set_cookie(k, v) return response - def render(self, template, qcontext=None, **kw): + def render(self, template, qcontext=None, lazy=True, **kw): """ Lazy render of QWeb template. The actual rendering of the given template will occur at then end of @@ -568,8 +568,12 @@ class HttpRequest(WebRequest): :param basestring template: template to render :param dict qcontext: Rendering context to use + :param dict lazy: Lazy rendering is processed later in wsgi response layer (default True) """ - return Response(template=template, qcontext=qcontext, **kw) + response = Response(template=template, qcontext=qcontext, **kw) + if not lazy: + return response.render() + return response def not_found(self, description=None): """ Helper for 404 response, return its result from the method diff --git a/openerp/tools/func.py b/openerp/tools/func.py index 89cdf9fb622..7d04c62a1c9 100644 --- a/openerp/tools/func.py +++ b/openerp/tools/func.py @@ -34,20 +34,19 @@ class lazy_property(object): """ def __init__(self, fget): self.fget = fget - self.name = fget.__name__ def __get__(self, obj, cls): if obj is None: return self value = self.fget(obj) - setattr(obj, self.name, value) + setattr(obj, self.fget.__name__, value) return value @staticmethod def reset_all(obj): """ Reset all lazy properties on the instance `obj`. """ cls = type(obj) - obj_dict = obj.__dict__ + obj_dict = vars(obj) for name in obj_dict.keys(): if isinstance(getattr(cls, name, None), lazy_property): obj_dict.pop(name)