ir_qweb.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. import os
  2. import re
  3. import logging
  4. from lxml import etree, html
  5. from subprocess import Popen, PIPE
  6. import openerp
  7. from openerp.osv import orm
  8. from openerp.tools import which
  9. from openerp.addons.base.ir.ir_qweb import AssetError, JavascriptAsset, QWebException
  10. _logger = logging.getLogger(__name__)
  11. class QWeb_less(orm.AbstractModel):
  12. _name = "website.qweb"
  13. _inherit = "website.qweb"
  14. def render_tag_call_assets(self, element, template_attributes, generated_attributes, qwebcontext):
  15. """ This special 't-call' tag can be used in order to aggregate/minify javascript and css assets"""
  16. if len(element):
  17. # An asset bundle is rendered in two differents contexts (when genereting html and
  18. # when generating the bundle itself) so they must be qwebcontext free
  19. # even '0' variable is forbidden
  20. template = qwebcontext.get('__template__')
  21. raise QWebException("t-call-assets cannot contain children nodes", template=template)
  22. xmlid = template_attributes['call-assets']
  23. cr, uid, context = [getattr(qwebcontext, attr) for attr in ('cr', 'uid', 'context')]
  24. bundle = AssetsBundle(xmlid, cr=cr, uid=uid, context=context, registry=self.pool)
  25. css = self.get_attr_bool(template_attributes.get('css'), default=True)
  26. js = self.get_attr_bool(template_attributes.get('js'), default=True)
  27. return bundle.to_html(css=css, js=js, debug=bool(qwebcontext.get('debug')))
  28. def render_tag_snippet(self, element, template_attributes, generated_attributes, qwebcontext):
  29. d = qwebcontext.copy()
  30. d[0] = self.render_element(element, template_attributes, generated_attributes, d)
  31. cr = d.get('request') and d['request'].cr or None
  32. uid = d.get('request') and d['request'].uid or None
  33. template = self.eval_format(template_attributes["snippet"], d)
  34. document = self.render(cr, uid, template, d)
  35. node = etree.fromstring(document)
  36. id = int(node.get('data-oe-id', self.pool['ir.model.data'].xmlid_to_res_id(cr, uid, template, raise_if_not_found=True)))
  37. node.set('data-oe-name', self.pool['ir.ui.view'].browse(cr, uid, id, context=qwebcontext.context).name)
  38. node.set('data-oe-type', "snippet")
  39. node.set('data-oe-thumbnail', template_attributes.get('thumbnail', "oe-thumbnail"))
  40. return html.tostring(node, encoding='UTF-8')
  41. class AssetsBundle(openerp.addons.base.ir.ir_qweb.AssetsBundle):
  42. rx_preprocess_imports = re.compile("""(@import\s?['"]([^'"]+)['"](;?))""")
  43. def parse(self):
  44. fragments = html.fragments_fromstring(self.html)
  45. for el in fragments:
  46. if isinstance(el, basestring):
  47. self.remains.append(el)
  48. elif isinstance(el, html.HtmlElement):
  49. src = el.get('src', '')
  50. href = el.get('href', '')
  51. atype = el.get('type')
  52. media = el.get('media')
  53. if el.tag == 'style':
  54. if atype == 'text/sass' or src.endswith('.sass'):
  55. self.stylesheets.append(SassAsset(self, inline=el.text, media=media))
  56. elif atype == 'text/less' or src.endswith('.less'):
  57. self.stylesheets.append(LessStylesheetAsset(self, inline=el.text, media=media))
  58. else:
  59. self.stylesheets.append(StylesheetAsset(self, inline=el.text, media=media))
  60. elif el.tag == 'link' and el.get('rel') == 'stylesheet' and self.can_aggregate(href):
  61. if href.endswith('.sass') or atype == 'text/sass':
  62. self.stylesheets.append(SassAsset(self, url=href, media=media))
  63. elif href.endswith('.less') or atype == 'text/less':
  64. self.stylesheets.append(LessStylesheetAsset(self, url=href, media=media))
  65. else:
  66. self.stylesheets.append(StylesheetAsset(self, url=href, media=media))
  67. elif el.tag == 'script' and not src:
  68. self.javascripts.append(JavascriptAsset(self, inline=el.text))
  69. elif el.tag == 'script' and self.can_aggregate(src):
  70. self.javascripts.append(JavascriptAsset(self, url=src))
  71. else:
  72. self.remains.append(html.tostring(el))
  73. else:
  74. try:
  75. self.remains.append(html.tostring(el))
  76. except Exception:
  77. # notYETimplementederror
  78. raise NotImplementedError
  79. def to_html(self, sep=None, css=True, js=True, debug=False):
  80. if sep is None:
  81. sep = '\n '
  82. response = []
  83. if debug:
  84. if css and self.stylesheets:
  85. self.preprocess_css()
  86. if self.css_errors:
  87. msg = '\n'.join(self.css_errors)
  88. self.stylesheets.append(StylesheetAsset(self, inline=self.css_message(msg)))
  89. for style in self.stylesheets:
  90. response.append(style.to_html())
  91. if js:
  92. for jscript in self.javascripts:
  93. response.append(jscript.to_html())
  94. else:
  95. url_for = self.context.get('url_for', lambda url: url)
  96. if css and self.stylesheets:
  97. href = '/web/css/%s/%s' % (self.xmlid, self.version)
  98. response.append('<link href="%s" rel="stylesheet"/>' % url_for(href))
  99. if js:
  100. src = '/web/js/%s/%s' % (self.xmlid, self.version)
  101. response.append('<script type="text/javascript" src="%s"></script>' % url_for(src))
  102. response.extend(self.remains)
  103. return sep + sep.join(response)
  104. def css(self):
  105. content = self.get_cache('css')
  106. if content is None:
  107. content = self.preprocess_css()
  108. if self.css_errors:
  109. msg = '\n'.join(self.css_errors)
  110. content += self.css_message(msg)
  111. # move up all @import rules to the top
  112. matches = []
  113. def push(matchobj):
  114. matches.append(matchobj.group(0))
  115. return ''
  116. content = re.sub(self.rx_css_import, push, content)
  117. matches.append(content)
  118. content = u'\n'.join(matches)
  119. if self.css_errors:
  120. return content
  121. self.set_cache('css', content)
  122. return content
  123. def set_cache(self, type, content):
  124. ira = self.registry['ir.attachment']
  125. ira.invalidate_bundle(self.cr, openerp.SUPERUSER_ID, type=type, xmlid=self.xmlid)
  126. url = '/web/%s/%s/%s' % (type, self.xmlid, self.version)
  127. ira.create(self.cr, openerp.SUPERUSER_ID, dict(
  128. datas=content.encode('utf8').encode('base64'),
  129. type='binary',
  130. name=url,
  131. url=url,
  132. ), context=self.context)
  133. def css_message(self, message):
  134. # '\A' == css content carriage return
  135. message = message.replace('\n', '\\A ').replace('"', '\\"')
  136. return """
  137. body:before {
  138. background: #ffc;
  139. width: 100%%;
  140. font-size: 14px;
  141. font-family: monospace;
  142. white-space: pre;
  143. content: "%s";
  144. }
  145. """ % message
  146. def preprocess_css(self):
  147. """
  148. Checks if the bundle contains any sass/less content, then compiles it to css.
  149. Returns the bundle's flat css.
  150. """
  151. for atype in (SassAsset, LessStylesheetAsset):
  152. assets = [asset for asset in self.stylesheets if isinstance(asset, atype)]
  153. if assets:
  154. cmd = assets[0].get_command()
  155. source = '\n'.join([asset.get_source() for asset in assets])
  156. compiled = self.compile_css(cmd, source)
  157. fragments = self.rx_css_split.split(compiled)
  158. at_rules = fragments.pop(0)
  159. if at_rules:
  160. # Sass and less moves @at-rules to the top in order to stay css 2.1 compatible
  161. self.stylesheets.insert(0, StylesheetAsset(self, inline=at_rules))
  162. while fragments:
  163. asset_id = fragments.pop(0)
  164. asset = next(asset for asset in self.stylesheets if asset.id == asset_id)
  165. asset._content = fragments.pop(0)
  166. return '\n'.join(asset.minify() for asset in self.stylesheets)
  167. def compile_sass(self):
  168. self.preprocess_css()
  169. def compile_css(self, cmd, source):
  170. """Sanitizes @import rules, remove duplicates @import rules, then compile"""
  171. imports = []
  172. def sanitize(matchobj):
  173. ref = matchobj.group(2)
  174. line = '@import "%s"%s' % (ref, matchobj.group(3))
  175. if '.' not in ref and line not in imports and not ref.startswith(('.', '/', '~')):
  176. imports.append(line)
  177. return line
  178. msg = "Local import '%s' is forbidden for security reasons." % ref
  179. _logger.warning(msg)
  180. self.css_errors.append(msg)
  181. return ''
  182. source = re.sub(self.rx_preprocess_imports, sanitize, source)
  183. try:
  184. compiler = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE)
  185. except Exception:
  186. msg = "Could not execute command %r" % cmd[0]
  187. _logger.error(msg)
  188. self.css_errors.append(msg)
  189. return ''
  190. result = compiler.communicate(input=source.encode('utf-8'))
  191. if compiler.returncode:
  192. error = self.get_preprocessor_error(''.join(result), source=source)
  193. _logger.warning(error)
  194. self.css_errors.append(error)
  195. return ''
  196. compiled = result[0].strip().decode('utf8')
  197. return compiled
  198. def get_preprocessor_error(self, stderr, source=None):
  199. """Improve and remove sensitive information from sass/less compilator error messages"""
  200. error = stderr.split('Load paths')[0].replace(' Use --trace for backtrace.', '')
  201. if 'Cannot load compass' in error:
  202. error += "Maybe you should install the compass gem using this extra argument:\n\n" \
  203. " $ sudo gem install compass --pre\n"
  204. error += "This error occured while compiling the bundle '%s' containing:" % self.xmlid
  205. for asset in self.stylesheets:
  206. if isinstance(asset, (SassAsset, LessStylesheetAsset)):
  207. error += '\n - %s' % (asset.url if asset.url else '<inline sass>')
  208. return error
  209. class StylesheetAsset(openerp.addons.base.ir.ir_qweb.StylesheetAsset):
  210. @property
  211. def content(self):
  212. if self._content is None:
  213. self._content = self.inline or self._fetch_content()
  214. return self._content
  215. def _fetch_content(self):
  216. try:
  217. content = openerp.addons.base.ir.ir_qweb.WebAsset._fetch_content(self)
  218. web_dir = os.path.dirname(self.url)
  219. if self.rx_import:
  220. content = self.rx_import.sub(
  221. r"""@import \1%s/""" % (web_dir,),
  222. content,
  223. )
  224. if self.rx_url:
  225. content = self.rx_url.sub(
  226. r"url(\1%s/" % (web_dir,),
  227. content,
  228. )
  229. if self.rx_charset:
  230. # remove charset declarations, we only support utf-8
  231. content = self.rx_charset.sub('', content)
  232. return content
  233. except AssetError, e:
  234. self.bundle.css_errors.append(e.message)
  235. return ''
  236. class SassAsset(StylesheetAsset, openerp.addons.base.ir.ir_qweb.SassAsset):
  237. def to_html(self):
  238. if self.url:
  239. ira = self.registry['ir.attachment']
  240. url = self.html_url % self.url
  241. domain = [('type', '=', 'binary'), ('url', '=', url)]
  242. ira_id = ira.search(self.cr, openerp.SUPERUSER_ID, domain, context=self.context)
  243. datas = self.content.encode('utf8').encode('base64')
  244. if ira_id:
  245. # TODO: update only if needed
  246. ira.write(self.cr, openerp.SUPERUSER_ID, ira_id, {'datas': datas}, context=self.context)
  247. else:
  248. ira.create(self.cr, openerp.SUPERUSER_ID, dict(
  249. datas=datas,
  250. mimetype='text/css',
  251. type='binary',
  252. name=url,
  253. url=url,
  254. ), context=self.context)
  255. return super(SassAsset, self).to_html()
  256. def get_command(self):
  257. defpath = os.environ.get('PATH', os.defpath).split(os.pathsep)
  258. sass = which('sass', path=os.pathsep.join(defpath))
  259. return [sass, '--stdin', '-t', 'compressed', '--unix-newlines', '--compass',
  260. '-r', 'bootstrap-sass']
  261. class LessStylesheetAsset(StylesheetAsset):
  262. html_url = '%s.css'
  263. rx_import = None
  264. def minify(self):
  265. return self.with_header()
  266. def to_html(self):
  267. if self.url:
  268. ira = self.registry['ir.attachment']
  269. url = self.html_url % self.url
  270. domain = [('type', '=', 'binary'), ('url', '=', url)]
  271. ira_id = ira.search(self.cr, openerp.SUPERUSER_ID, domain, context=self.context)
  272. datas = self.content.encode('utf8').encode('base64')
  273. if ira_id:
  274. # TODO: update only if needed
  275. ira.write(self.cr, openerp.SUPERUSER_ID, ira_id, {'datas': datas}, context=self.context)
  276. else:
  277. ira.create(self.cr, openerp.SUPERUSER_ID, dict(
  278. datas=datas,
  279. mimetype='text/css',
  280. type='binary',
  281. name=url,
  282. url=url,
  283. ), context=self.context)
  284. return super(LessStylesheetAsset, self).to_html()
  285. def get_source(self):
  286. content = self.inline or self._fetch_content()
  287. return "/*! %s */\n%s" % (self.id, content)
  288. def get_command(self):
  289. defpath = os.environ.get('PATH', os.defpath).split(os.pathsep)
  290. if os.name == 'nt':
  291. lessc = which('lessc.cmd', path=os.pathsep.join(defpath))
  292. else:
  293. lessc = which('lessc', path=os.pathsep.join(defpath))
  294. webpath = openerp.http.addons_manifest['web']['addons_path']
  295. lesspath = os.path.join(webpath, 'web', 'static', 'lib', 'bootstrap', 'less')
  296. return [lessc, '-', '--clean-css', '--no-js', '--no-color', '--include-path=%s' % lesspath]