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