1 # -*- coding: utf-8 -*-
3 Website-context rendering needs to add some metadata to rendered fields,
4 as well as render a few fields differently.
6 Also, adds methods to convert values back to openerp models.
21 from dateutil import parser
22 from lxml import etree, html
23 from PIL import Image as I
24 import openerp.modules
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
33 REMOTE_CONNECTION_TIMEOUT = 2.5
35 logger = logging.getLogger(__name__)
37 class QWeb(orm.AbstractModel):
38 """ QWeb object for rendering stuff in the website context
40 _name = 'website.qweb'
48 re_remove_spaces = re.compile('\s+')
49 PRESERVE_WHITESPACE = [
56 def add_template(self, qcontext, name, node):
57 # preprocessing for multilang static urls
59 for tag, attr in self.URL_ATTRS.iteritems():
60 for e in node.iterdescendants(tag=tag):
63 e.set(attr, qcontext.get('url_for')(url))
64 super(QWeb, self).add_template(qcontext, name, node)
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)
69 if request.website and att == self.URL_ATTRS.get(element.tag) and isinstance(val, basestring):
70 val = qwebcontext.get('url_for')(val)
73 def get_converter_for(self, field_type):
75 'website.qweb.field.' + field_type,
76 self.pool['website.qweb.field'])
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)
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)
91 class Field(orm.AbstractModel):
92 _name = 'website.qweb.field'
93 _inherit = 'ir.qweb.field'
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)]
101 placeholder = options.get('placeholder') \
102 or source_element.get('placeholder') \
103 or getattr(column, 'placeholder', None)
105 attrs.append(('placeholder', placeholder))
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),
114 def value_from_string(self, value):
117 def from_html(self, cr, uid, model, column, element, context=None):
118 return self.value_from_string(element.text_content().strip())
120 def qweb_object(self):
121 return self.pool['website.qweb']
123 class Integer(orm.AbstractModel):
124 _name = 'website.qweb.field.integer'
125 _inherit = ['website.qweb.field']
127 value_from_string = int
129 class Float(orm.AbstractModel):
130 _name = 'website.qweb.field.float'
131 _inherit = ['website.qweb.field', 'ir.qweb.field.float']
133 def from_html(self, cr, uid, model, column, element, context=None):
134 lang = self.user_lang(cr, uid, context=context)
136 value = element.text_content().strip()
138 return float(value.replace(lang.thousands_sep, '')
139 .replace(lang.decimal_point, '.'))
142 def parse_fuzzy(in_format, value):
143 day_first = in_format.find('%d') < in_format.find('%m')
145 if '%y' in in_format:
146 year_first = in_format.find('%y') < in_format.find('%d')
148 year_first = in_format.find('%Y') < in_format.find('%d')
150 return parser.parse(value, dayfirst=day_first, yearfirst=year_first)
152 class Date(orm.AbstractModel):
153 _name = 'website.qweb.field.date'
154 _inherit = ['website.qweb.field', 'ir.qweb.field.date']
156 def attributes(self, cr, uid, field_name, record, options,
157 source_element, g_att, t_att, qweb_context,
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])])
164 def from_html(self, cr, uid, model, column, element, context=None):
165 value = element.text_content().strip()
166 if not value: return False
168 datetime.datetime.strptime(value, DEFAULT_SERVER_DATE_FORMAT)
171 class DateTime(orm.AbstractModel):
172 _name = 'website.qweb.field.datetime'
173 _inherit = ['website.qweb.field', 'ir.qweb.field.datetime']
175 def attributes(self, cr, uid, field_name, record, options,
176 source_element, g_att, t_att, qweb_context,
178 value = record[field_name]
179 if isinstance(value, basestring):
180 value = datetime.datetime.strptime(
181 value, DEFAULT_SERVER_DATETIME_FORMAT)
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)
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)
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
200 # parse from string to datetime
201 dt = datetime.datetime.strptime(value, DEFAULT_SERVER_DATETIME_FORMAT)
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']
208 user_tz = pytz.timezone(tz_name)
211 dt = user_tz.localize(dt).astimezone(utc)
214 "Failed to convert the value for a field of the model"
215 " %s back from the user's timezone (%s) to UTC",
219 # format back to string
220 return dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT)
222 class Text(orm.AbstractModel):
223 _name = 'website.qweb.field.text'
224 _inherit = ['website.qweb.field', 'ir.qweb.field.text']
226 def from_html(self, cr, uid, model, column, element, context=None):
227 return html_to_text(element)
229 class Selection(orm.AbstractModel):
230 _name = 'website.qweb.field.selection'
231 _inherit = ['website.qweb.field', 'ir.qweb.field.selection']
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):
242 raise ValueError(u"No value found for label %s in selection %s" % (
245 class ManyToOne(orm.AbstractModel):
246 _name = 'website.qweb.field.many2one'
247 _inherit = ['website.qweb.field', 'ir.qweb.field.many2one']
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)
258 # if anything blows up, just ignore it and bail
261 [obj] = Model.read(cr, uid, [id], [field])
263 (m2o_id, _) = obj[field]
264 # assume _rec_name and write directly to it
265 M2O.write(cr, uid, [m2o_id], {
269 logger.exception("Could not save %r to m2o field %s of model %s",
270 value, field, Model._name)
272 # not necessary, but might as well be explicit about it
275 class HTML(orm.AbstractModel):
276 _name = 'website.qweb.field.html'
277 _inherit = ['website.qweb.field', 'ir.qweb.field.html']
279 def from_html(self, cr, uid, model, column, element, context=None):
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)
287 class Image(orm.AbstractModel):
292 set as attribute on the generated <img> tag
294 _name = 'website.qweb.field.image'
295 _inherit = ['website.qweb.field', 'ir.qweb.field.image']
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 " \
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)
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))
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)
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)
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')
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)
337 if self.local_url_re.match(url_object.path):
338 return self.load_local_url(url)
340 return self.load_remote_url(url)
342 def load_local_url(self, url):
343 match = self.local_url_re.match(urlparse.urlsplit(url).path)
345 rest = match.group('rest')
346 for sep in os.sep, os.altsep:
347 if sep and sep != '/':
348 rest.replace(sep, '/')
350 path = openerp.modules.get_module_resource(
351 match.group('module'), 'static', *(rest.split('/')))
357 with open(path, 'rb') as f:
358 # force complete image load to ensure it's valid image data
362 return f.read().encode('base64')
364 logger.exception("Failed to load local image %r", url)
367 def load_remote_url(self, url):
369 # should probably remove remote URLs entirely:
370 # * in fields, downloading them without blowing up the server is a
372 # * in views, may trigger mixed content warnings if HTTPS CMS
373 # linking to HTTP images
374 # implement drag & drop image upload to mitigate?
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
382 logger.exception("Failed to load remote image %r", url)
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')
391 class Monetary(orm.AbstractModel):
392 _name = 'website.qweb.field.monetary'
393 _inherit = ['website.qweb.field', 'ir.qweb.field.monetary']
395 def from_html(self, cr, uid, model, column, element, context=None):
396 lang = self.user_lang(cr, uid, context=context)
398 value = element.find('span').text.strip()
400 return float(value.replace(lang.thousands_sep, '')
401 .replace(lang.decimal_point, '.'))
403 class Duration(orm.AbstractModel):
404 _name = 'website.qweb.field.duration'
406 'ir.qweb.field.duration',
407 'website.qweb.field.float',
410 def attributes(self, cr, uid, field_name, record, options,
411 source_element, g_att, t_att, qweb_context,
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])])
418 def from_html(self, cr, uid, model, column, element, context=None):
419 value = element.text_content().strip()
421 # non-localized value
425 class RelativeDatetime(orm.AbstractModel):
426 _name = 'website.qweb.field.relative'
428 'ir.qweb.field.relative',
429 'website.qweb.field.datetime',
432 # get formatting from ir.qweb.field.relative but edition/save from datetime
435 class Contact(orm.AbstractModel):
436 _name = 'website.qweb.field.contact'
437 _inherit = ['ir.qweb.field.contact', 'website.qweb.field.many2one']
439 class QwebView(orm.AbstractModel):
440 _name = 'website.qweb.field.qweb'
441 _inherit = ['ir.qweb.field.qweb']
444 def html_to_text(element):
445 """ Converts HTML content with HTML-specified line breaks (br, p, div, ...)
446 in roughly equivalent textual content.
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
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
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
468 :param element: lxml.html content
469 :returns: corresponding pure-text output
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
476 _wrap(element, output)
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
482 r'[ \t\r\f]*\n[ \t\r\f]*',
484 ''.join(_realize_padding(output)).strip())
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
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'
493 def _collapse_whitespace(text):
494 """ Collapses sequences of whitespace characters in ``text`` to a single
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.
505 if isinstance(item, int):
506 padding = max(padding, item)
514 # leftover padding irrelevant as the output will be stripped
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``
520 :type wrapper: basestring | int
522 output.append(wrapper)
524 output.append(_collapse_whitespace(element.text))
525 for child in element:
526 _element_to_text(child, output)
527 output.append(wrapper)
529 def _element_to_text(e, output):
532 elif e.tag in _PADDED_BLOCK:
534 elif e.tag in _MISC_BLOCK:
541 output.append(_collapse_whitespace(e.tail))