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