[ADD] CDN support for website
[odoo/odoo.git] / addons / website / models / ir_qweb.py
1 # -*- coding: utf-8 -*-
2 """
3 Website-context rendering needs to add some metadata to rendered fields,
4 as well as render a few fields differently.
5
6 Also, adds methods to convert values back to openerp models.
7 """
8
9 import cStringIO
10 import datetime
11 import itertools
12 import logging
13 import os
14 import urllib2
15 import urlparse
16 import re
17
18 import pytz
19 import werkzeug.urls
20 import werkzeug.utils
21 from dateutil import parser
22 from lxml import etree, html
23 from PIL import Image as I
24 import openerp.modules
25
26 import openerp
27 from openerp.osv import orm, fields
28 from openerp.tools import ustr, DEFAULT_SERVER_DATE_FORMAT, DEFAULT_SERVER_DATETIME_FORMAT
29 from openerp.tools import html_escape as escape
30 from openerp.addons.web.http import request
31 from openerp.addons.base.ir import ir_qweb
32
33 REMOTE_CONNECTION_TIMEOUT = 2.5
34
35 logger = logging.getLogger(__name__)
36
37 class QWeb(orm.AbstractModel):
38     """ QWeb object for rendering stuff in the website context
39     """
40     _name = 'website.qweb'
41     _inherit = 'ir.qweb'
42
43     URL_ATTRS = {
44         'form': 'action',
45         'a': 'href',
46     }
47
48     re_remove_spaces = re.compile('\s+')
49     PRESERVE_WHITESPACE = [
50         'pre',
51         'textarea',
52         'script',
53         'style',
54     ]
55
56     CDN_TRIGGERS = {
57         'link':    'href',
58         'script':  'src',
59         'img':     'src',
60     }
61
62     def render_attribute(self, element, name, value, qwebcontext):
63         context = qwebcontext.context or {}
64         if not context.get('rendering_bundle'):
65             if name == self.URL_ATTRS.get(element.tag):
66                 value = qwebcontext.get('url_for')(value)
67             if request and request.website and request.website.cdn_activated and name == self.CDN_TRIGGERS.get(element.tag):
68                 value = request.website.get_cdn_url(value)
69
70         return super(QWeb, self).render_attribute(element, name, value, qwebcontext)
71
72     def render_tag_call_assets(self, element, template_attributes, generated_attributes, qwebcontext):
73         if request and request.website and request.website.cdn_activated:
74             if qwebcontext.context is None:
75                 qwebcontext.context = {}
76             qwebcontext.context['url_for'] = request.website.get_cdn_url
77         return super(QWeb, self).render_tag_call_assets(element, template_attributes, generated_attributes, qwebcontext)
78
79     def get_converter_for(self, field_type):
80         return self.pool.get(
81             'website.qweb.field.' + field_type,
82             self.pool['website.qweb.field'])
83
84     def render_text(self, text, element, qwebcontext):
85         compress = request and not request.debug and request.website and request.website.compress_html
86         if compress and element.tag not in self.PRESERVE_WHITESPACE:
87             text = self.re_remove_spaces.sub(' ', text.lstrip())
88         return super(QWeb, self).render_text(text, element, qwebcontext)
89
90     def render_tail(self, tail, element, qwebcontext):
91         compress = request and not request.debug and request.website and request.website.compress_html
92         if compress and element.getparent().tag not in self.PRESERVE_WHITESPACE:
93             # No need to recurse because those tags children are not html5 parser friendly
94             tail = self.re_remove_spaces.sub(' ', tail.rstrip())
95         return super(QWeb, self).render_tail(tail, element, qwebcontext)
96
97 class Field(orm.AbstractModel):
98     _name = 'website.qweb.field'
99     _inherit = 'ir.qweb.field'
100
101     def attributes(self, cr, uid, field_name, record, options,
102                    source_element, g_att, t_att, qweb_context, context=None):
103         if options is None: options = {}
104         column = record._model._all_columns[field_name].column
105         attrs = [('data-oe-translate', 1 if column.translate else 0)]
106
107         placeholder = options.get('placeholder') \
108                    or source_element.get('placeholder') \
109                    or getattr(column, 'placeholder', None)
110         if placeholder:
111             attrs.append(('placeholder', placeholder))
112
113         return itertools.chain(
114             super(Field, self).attributes(cr, uid, field_name, record, options,
115                                           source_element, g_att, t_att,
116                                           qweb_context, context=context),
117             attrs
118         )
119
120     def value_from_string(self, value):
121         return value
122
123     def from_html(self, cr, uid, model, column, element, context=None):
124         return self.value_from_string(element.text_content().strip())
125
126     def qweb_object(self):
127         return self.pool['website.qweb']
128
129 class Integer(orm.AbstractModel):
130     _name = 'website.qweb.field.integer'
131     _inherit = ['website.qweb.field']
132
133     value_from_string = int
134
135 class Float(orm.AbstractModel):
136     _name = 'website.qweb.field.float'
137     _inherit = ['website.qweb.field', 'ir.qweb.field.float']
138
139     def from_html(self, cr, uid, model, column, element, context=None):
140         lang = self.user_lang(cr, uid, context=context)
141
142         value = element.text_content().strip()
143
144         return float(value.replace(lang.thousands_sep, '')
145                           .replace(lang.decimal_point, '.'))
146
147
148 def parse_fuzzy(in_format, value):
149     day_first = in_format.find('%d') < in_format.find('%m')
150
151     if '%y' in in_format:
152         year_first = in_format.find('%y') < in_format.find('%d')
153     else:
154         year_first = in_format.find('%Y') < in_format.find('%d')
155
156     return parser.parse(value, dayfirst=day_first, yearfirst=year_first)
157
158 class Date(orm.AbstractModel):
159     _name = 'website.qweb.field.date'
160     _inherit = ['website.qweb.field', 'ir.qweb.field.date']
161
162     def attributes(self, cr, uid, field_name, record, options,
163                    source_element, g_att, t_att, qweb_context,
164                    context=None):
165         attrs = super(Date, self).attributes(
166             cr, uid, field_name, record, options, source_element, g_att, t_att,
167             qweb_context, context=None)
168         return itertools.chain(attrs, [('data-oe-original', record[field_name])])
169
170     def from_html(self, cr, uid, model, column, element, context=None):
171         value = element.text_content().strip()
172         if not value: return False
173
174         datetime.datetime.strptime(value, DEFAULT_SERVER_DATE_FORMAT)
175         return value
176
177 class DateTime(orm.AbstractModel):
178     _name = 'website.qweb.field.datetime'
179     _inherit = ['website.qweb.field', 'ir.qweb.field.datetime']
180
181     def attributes(self, cr, uid, field_name, record, options,
182                    source_element, g_att, t_att, qweb_context,
183                    context=None):
184         value = record[field_name]
185         if isinstance(value, basestring):
186             value = datetime.datetime.strptime(
187                 value, DEFAULT_SERVER_DATETIME_FORMAT)
188         if value:
189             # convert from UTC (server timezone) to user timezone
190             value = fields.datetime.context_timestamp(
191                 cr, uid, timestamp=value, context=context)
192             value = value.strftime(DEFAULT_SERVER_DATETIME_FORMAT)
193
194         attrs = super(DateTime, self).attributes(
195             cr, uid, field_name, record, options, source_element, g_att, t_att,
196             qweb_context, context=None)
197         return itertools.chain(attrs, [
198             ('data-oe-original', value)
199         ])
200
201     def from_html(self, cr, uid, model, column, element, context=None):
202         if context is None: context = {}
203         value = element.text_content().strip()
204         if not value: return False
205
206         # parse from string to datetime
207         dt = datetime.datetime.strptime(value, DEFAULT_SERVER_DATETIME_FORMAT)
208
209         # convert back from user's timezone to UTC
210         tz_name = context.get('tz') \
211             or self.pool['res.users'].read(cr, openerp.SUPERUSER_ID, uid, ['tz'], context=context)['tz']
212         if tz_name:
213             try:
214                 user_tz = pytz.timezone(tz_name)
215                 utc = pytz.utc
216
217                 dt = user_tz.localize(dt).astimezone(utc)
218             except Exception:
219                 logger.warn(
220                     "Failed to convert the value for a field of the model"
221                     " %s back from the user's timezone (%s) to UTC",
222                     model, tz_name,
223                     exc_info=True)
224
225         # format back to string
226         return dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT)
227
228 class Text(orm.AbstractModel):
229     _name = 'website.qweb.field.text'
230     _inherit = ['website.qweb.field', 'ir.qweb.field.text']
231
232     def from_html(self, cr, uid, model, column, element, context=None):
233         return html_to_text(element)
234
235 class Selection(orm.AbstractModel):
236     _name = 'website.qweb.field.selection'
237     _inherit = ['website.qweb.field', 'ir.qweb.field.selection']
238
239     def from_html(self, cr, uid, model, column, element, context=None):
240         value = element.text_content().strip()
241         selection = column.reify(cr, uid, model, column, context=context)
242         for k, v in selection:
243             if isinstance(v, str):
244                 v = ustr(v)
245             if value == v:
246                 return k
247
248         raise ValueError(u"No value found for label %s in selection %s" % (
249                          value, selection))
250
251 class ManyToOne(orm.AbstractModel):
252     _name = 'website.qweb.field.many2one'
253     _inherit = ['website.qweb.field', 'ir.qweb.field.many2one']
254
255     def from_html(self, cr, uid, model, column, element, context=None):
256         # FIXME: layering violations all the things
257         Model = self.pool[element.get('data-oe-model')]
258         M2O = self.pool[column._obj]
259         field = element.get('data-oe-field')
260         id = int(element.get('data-oe-id'))
261         # FIXME: weird things are going to happen for char-type _rec_name
262         value = html_to_text(element)
263
264         # if anything blows up, just ignore it and bail
265         try:
266             # get parent record
267             [obj] = Model.read(cr, uid, [id], [field])
268             # get m2o record id
269             (m2o_id, _) = obj[field]
270             # assume _rec_name and write directly to it
271             M2O.write(cr, uid, [m2o_id], {
272                 M2O._rec_name: value
273             }, context=context)
274         except:
275             logger.exception("Could not save %r to m2o field %s of model %s",
276                              value, field, Model._name)
277
278         # not necessary, but might as well be explicit about it
279         return None
280
281 class HTML(orm.AbstractModel):
282     _name = 'website.qweb.field.html'
283     _inherit = ['website.qweb.field', 'ir.qweb.field.html']
284
285     def from_html(self, cr, uid, model, column, element, context=None):
286         content = []
287         if element.text: content.append(element.text)
288         content.extend(html.tostring(child)
289                        for child in element.iterchildren(tag=etree.Element))
290         return '\n'.join(content)
291
292
293 class Image(orm.AbstractModel):
294     """
295     Widget options:
296
297     ``class``
298         set as attribute on the generated <img> tag
299     """
300     _name = 'website.qweb.field.image'
301     _inherit = ['website.qweb.field', 'ir.qweb.field.image']
302
303     def to_html(self, cr, uid, field_name, record, options,
304                 source_element, t_att, g_att, qweb_context, context=None):
305         assert source_element.tag != 'img',\
306             "Oddly enough, the root tag of an image field can not be img. " \
307             "That is because the image goes into the tag, or it gets the " \
308             "hose again."
309
310         return super(Image, self).to_html(
311             cr, uid, field_name, record, options,
312             source_element, t_att, g_att, qweb_context, context=context)
313
314     def record_to_html(self, cr, uid, field_name, record, column, options=None, context=None):
315         if options is None: options = {}
316         aclasses = ['img', 'img-responsive'] + options.get('class', '').split()
317         classes = ' '.join(itertools.imap(escape, aclasses))
318
319         max_size = None
320         max_width, max_height = options.get('max_width', 0), options.get('max_height', 0)
321         if max_width or max_height:
322             max_size = '%sx%s' % (max_width, max_height)
323
324         src = self.pool['website'].image_url(cr, uid, record, field_name, max_size)
325         img = '<img class="%s" src="%s"/>' % (classes, src)
326         return ir_qweb.HTMLSafe(img)
327
328     local_url_re = re.compile(r'^/(?P<module>[^]]+)/static/(?P<rest>.+)$')
329     def from_html(self, cr, uid, model, column, element, context=None):
330         url = element.find('img').get('src')
331
332         url_object = urlparse.urlsplit(url)
333         if url_object.path.startswith('/website/image'):
334             # url might be /website/image/<model>/<id>[_<checksum>]/<field>[/<width>x<height>]
335             fragments = url_object.path.split('/')
336             query = dict(urlparse.parse_qsl(url_object.query))
337             model = query.get('model', fragments[3])
338             oid = query.get('id', fragments[4].split('_')[0])
339             field = query.get('field', fragments[5])
340             item = self.pool[model].browse(cr, uid, int(oid), context=context)
341             return item[field]
342
343         if self.local_url_re.match(url_object.path):
344             return self.load_local_url(url)
345
346         return self.load_remote_url(url)
347
348     def load_local_url(self, url):
349         match = self.local_url_re.match(urlparse.urlsplit(url).path)
350
351         rest = match.group('rest')
352         for sep in os.sep, os.altsep:
353             if sep and sep != '/':
354                 rest.replace(sep, '/')
355
356         path = openerp.modules.get_module_resource(
357             match.group('module'), 'static', *(rest.split('/')))
358
359         if not path:
360             return None
361
362         try:
363             with open(path, 'rb') as f:
364                 # force complete image load to ensure it's valid image data
365                 image = I.open(f)
366                 image.load()
367                 f.seek(0)
368                 return f.read().encode('base64')
369         except Exception:
370             logger.exception("Failed to load local image %r", url)
371             return None
372
373     def load_remote_url(self, url):
374         try:
375             # should probably remove remote URLs entirely:
376             # * in fields, downloading them without blowing up the server is a
377             #   challenge
378             # * in views, may trigger mixed content warnings if HTTPS CMS
379             #   linking to HTTP images
380             # implement drag & drop image upload to mitigate?
381
382             req = urllib2.urlopen(url, timeout=REMOTE_CONNECTION_TIMEOUT)
383             # PIL needs a seekable file-like image, urllib result is not seekable
384             image = I.open(cStringIO.StringIO(req.read()))
385             # force a complete load of the image data to validate it
386             image.load()
387         except Exception:
388             logger.exception("Failed to load remote image %r", url)
389             return None
390
391         # don't use original data in case weird stuff was smuggled in, with
392         # luck PIL will remove some of it?
393         out = cStringIO.StringIO()
394         image.save(out, image.format)
395         return out.getvalue().encode('base64')
396
397 class Monetary(orm.AbstractModel):
398     _name = 'website.qweb.field.monetary'
399     _inherit = ['website.qweb.field', 'ir.qweb.field.monetary']
400
401     def from_html(self, cr, uid, model, column, element, context=None):
402         lang = self.user_lang(cr, uid, context=context)
403
404         value = element.find('span').text.strip()
405
406         return float(value.replace(lang.thousands_sep, '')
407                           .replace(lang.decimal_point, '.'))
408
409 class Duration(orm.AbstractModel):
410     _name = 'website.qweb.field.duration'
411     _inherit = [
412         'ir.qweb.field.duration',
413         'website.qweb.field.float',
414     ]
415
416     def attributes(self, cr, uid, field_name, record, options,
417                    source_element, g_att, t_att, qweb_context,
418                    context=None):
419         attrs = super(Duration, self).attributes(
420             cr, uid, field_name, record, options, source_element, g_att, t_att,
421             qweb_context, context=None)
422         return itertools.chain(attrs, [('data-oe-original', record[field_name])])
423
424     def from_html(self, cr, uid, model, column, element, context=None):
425         value = element.text_content().strip()
426
427         # non-localized value
428         return float(value)
429
430
431 class RelativeDatetime(orm.AbstractModel):
432     _name = 'website.qweb.field.relative'
433     _inherit = [
434         'ir.qweb.field.relative',
435         'website.qweb.field.datetime',
436     ]
437
438     # get formatting from ir.qweb.field.relative but edition/save from datetime
439
440
441 class Contact(orm.AbstractModel):
442     _name = 'website.qweb.field.contact'
443     _inherit = ['ir.qweb.field.contact', 'website.qweb.field.many2one']
444
445     def from_html(self, cr, uid, model, column, element, context=None):
446         return None
447
448 class QwebView(orm.AbstractModel):
449     _name = 'website.qweb.field.qweb'
450     _inherit = ['ir.qweb.field.qweb']
451
452
453 def html_to_text(element):
454     """ Converts HTML content with HTML-specified line breaks (br, p, div, ...)
455     in roughly equivalent textual content.
456
457     Used to replace and fixup the roundtripping of text and m2o: when using
458     libxml 2.8.0 (but not 2.9.1) and parsing HTML with lxml.html.fromstring
459     whitespace text nodes (text nodes composed *solely* of whitespace) are
460     stripped out with no recourse, and fundamentally relying on newlines
461     being in the text (e.g. inserted during user edition) is probably poor form
462     anyway.
463
464     -> this utility function collapses whitespace sequences and replaces
465        nodes by roughly corresponding linebreaks
466        * p are pre-and post-fixed by 2 newlines
467        * br are replaced by a single newline
468        * block-level elements not already mentioned are pre- and post-fixed by
469          a single newline
470
471     ought be somewhat similar (but much less high-tech) to aaronsw's html2text.
472     the latter produces full-blown markdown, our text -> html converter only
473     replaces newlines by <br> elements at this point so we're reverting that,
474     and a few more newline-ish elements in case the user tried to add
475     newlines/paragraphs into the text field
476
477     :param element: lxml.html content
478     :returns: corresponding pure-text output
479     """
480
481     # output is a list of str | int. Integers are padding requests (in minimum
482     # number of newlines). When multiple padding requests, fold them into the
483     # biggest one
484     output = []
485     _wrap(element, output)
486
487     # remove any leading or tailing whitespace, replace sequences of
488     # (whitespace)\n(whitespace) by a single newline, where (whitespace) is a
489     # non-newline whitespace in this case
490     return re.sub(
491         r'[ \t\r\f]*\n[ \t\r\f]*',
492         '\n',
493         ''.join(_realize_padding(output)).strip())
494
495 _PADDED_BLOCK = set('p h1 h2 h3 h4 h5 h6'.split())
496 # https://developer.mozilla.org/en-US/docs/HTML/Block-level_elements minus p
497 _MISC_BLOCK = set((
498     'address article aside audio blockquote canvas dd dl div figcaption figure'
499     ' footer form header hgroup hr ol output pre section tfoot ul video'
500 ).split())
501
502 def _collapse_whitespace(text):
503     """ Collapses sequences of whitespace characters in ``text`` to a single
504     space
505     """
506     return re.sub('\s+', ' ', text)
507 def _realize_padding(it):
508     """ Fold and convert padding requests: integers in the output sequence are
509     requests for at least n newlines of padding. Runs thereof can be collapsed
510     into the largest requests and converted to newlines.
511     """
512     padding = None
513     for item in it:
514         if isinstance(item, int):
515             padding = max(padding, item)
516             continue
517
518         if padding:
519             yield '\n' * padding
520             padding = None
521
522         yield item
523     # leftover padding irrelevant as the output will be stripped
524
525 def _wrap(element, output, wrapper=u''):
526     """ Recursively extracts text from ``element`` (via _element_to_text), and
527     wraps it all in ``wrapper``. Extracted text is added to ``output``
528
529     :type wrapper: basestring | int
530     """
531     output.append(wrapper)
532     if element.text:
533         output.append(_collapse_whitespace(element.text))
534     for child in element:
535         _element_to_text(child, output)
536     output.append(wrapper)
537
538 def _element_to_text(e, output):
539     if e.tag == 'br':
540         output.append(u'\n')
541     elif e.tag in _PADDED_BLOCK:
542         _wrap(e, output, 2)
543     elif e.tag in _MISC_BLOCK:
544         _wrap(e, output, 1)
545     else:
546         # inline
547         _wrap(e, output)
548
549     if e.tail:
550         output.append(_collapse_whitespace(e.tail))