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