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 = [
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)
70 return super(QWeb, self).render_attribute(element, name, value, qwebcontext)
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)
79 def get_converter_for(self, field_type):
81 'website.qweb.field.' + field_type,
82 self.pool['website.qweb.field'])
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)
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)
97 class Field(orm.AbstractModel):
98 _name = 'website.qweb.field'
99 _inherit = 'ir.qweb.field'
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)]
107 placeholder = options.get('placeholder') \
108 or source_element.get('placeholder') \
109 or getattr(column, 'placeholder', None)
111 attrs.append(('placeholder', placeholder))
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),
120 def value_from_string(self, value):
123 def from_html(self, cr, uid, model, column, element, context=None):
124 return self.value_from_string(element.text_content().strip())
126 def qweb_object(self):
127 return self.pool['website.qweb']
129 class Integer(orm.AbstractModel):
130 _name = 'website.qweb.field.integer'
131 _inherit = ['website.qweb.field']
133 value_from_string = int
135 class Float(orm.AbstractModel):
136 _name = 'website.qweb.field.float'
137 _inherit = ['website.qweb.field', 'ir.qweb.field.float']
139 def from_html(self, cr, uid, model, column, element, context=None):
140 lang = self.user_lang(cr, uid, context=context)
142 value = element.text_content().strip()
144 return float(value.replace(lang.thousands_sep, '')
145 .replace(lang.decimal_point, '.'))
148 def parse_fuzzy(in_format, value):
149 day_first = in_format.find('%d') < in_format.find('%m')
151 if '%y' in in_format:
152 year_first = in_format.find('%y') < in_format.find('%d')
154 year_first = in_format.find('%Y') < in_format.find('%d')
156 return parser.parse(value, dayfirst=day_first, yearfirst=year_first)
158 class Date(orm.AbstractModel):
159 _name = 'website.qweb.field.date'
160 _inherit = ['website.qweb.field', 'ir.qweb.field.date']
162 def attributes(self, cr, uid, field_name, record, options,
163 source_element, g_att, t_att, qweb_context,
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])])
170 def from_html(self, cr, uid, model, column, element, context=None):
171 value = element.text_content().strip()
172 if not value: return False
174 datetime.datetime.strptime(value, DEFAULT_SERVER_DATE_FORMAT)
177 class DateTime(orm.AbstractModel):
178 _name = 'website.qweb.field.datetime'
179 _inherit = ['website.qweb.field', 'ir.qweb.field.datetime']
181 def attributes(self, cr, uid, field_name, record, options,
182 source_element, g_att, t_att, qweb_context,
184 value = record[field_name]
185 if isinstance(value, basestring):
186 value = datetime.datetime.strptime(
187 value, DEFAULT_SERVER_DATETIME_FORMAT)
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)
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)
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
206 # parse from string to datetime
207 dt = datetime.datetime.strptime(value, DEFAULT_SERVER_DATETIME_FORMAT)
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']
214 user_tz = pytz.timezone(tz_name)
217 dt = user_tz.localize(dt).astimezone(utc)
220 "Failed to convert the value for a field of the model"
221 " %s back from the user's timezone (%s) to UTC",
225 # format back to string
226 return dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT)
228 class Text(orm.AbstractModel):
229 _name = 'website.qweb.field.text'
230 _inherit = ['website.qweb.field', 'ir.qweb.field.text']
232 def from_html(self, cr, uid, model, column, element, context=None):
233 return html_to_text(element)
235 class Selection(orm.AbstractModel):
236 _name = 'website.qweb.field.selection'
237 _inherit = ['website.qweb.field', 'ir.qweb.field.selection']
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):
248 raise ValueError(u"No value found for label %s in selection %s" % (
251 class ManyToOne(orm.AbstractModel):
252 _name = 'website.qweb.field.many2one'
253 _inherit = ['website.qweb.field', 'ir.qweb.field.many2one']
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)
264 # if anything blows up, just ignore it and bail
267 [obj] = Model.read(cr, uid, [id], [field])
269 (m2o_id, _) = obj[field]
270 # assume _rec_name and write directly to it
271 M2O.write(cr, uid, [m2o_id], {
275 logger.exception("Could not save %r to m2o field %s of model %s",
276 value, field, Model._name)
278 # not necessary, but might as well be explicit about it
281 class HTML(orm.AbstractModel):
282 _name = 'website.qweb.field.html'
283 _inherit = ['website.qweb.field', 'ir.qweb.field.html']
285 def from_html(self, cr, uid, model, column, element, context=None):
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)
293 class Image(orm.AbstractModel):
298 set as attribute on the generated <img> tag
300 _name = 'website.qweb.field.image'
301 _inherit = ['website.qweb.field', 'ir.qweb.field.image']
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 " \
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)
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))
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)
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)
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')
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)
343 if self.local_url_re.match(url_object.path):
344 return self.load_local_url(url)
346 return self.load_remote_url(url)
348 def load_local_url(self, url):
349 match = self.local_url_re.match(urlparse.urlsplit(url).path)
351 rest = match.group('rest')
352 for sep in os.sep, os.altsep:
353 if sep and sep != '/':
354 rest.replace(sep, '/')
356 path = openerp.modules.get_module_resource(
357 match.group('module'), 'static', *(rest.split('/')))
363 with open(path, 'rb') as f:
364 # force complete image load to ensure it's valid image data
368 return f.read().encode('base64')
370 logger.exception("Failed to load local image %r", url)
373 def load_remote_url(self, url):
375 # should probably remove remote URLs entirely:
376 # * in fields, downloading them without blowing up the server is a
378 # * in views, may trigger mixed content warnings if HTTPS CMS
379 # linking to HTTP images
380 # implement drag & drop image upload to mitigate?
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
388 logger.exception("Failed to load remote image %r", url)
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')
397 class Monetary(orm.AbstractModel):
398 _name = 'website.qweb.field.monetary'
399 _inherit = ['website.qweb.field', 'ir.qweb.field.monetary']
401 def from_html(self, cr, uid, model, column, element, context=None):
402 lang = self.user_lang(cr, uid, context=context)
404 value = element.find('span').text.strip()
406 return float(value.replace(lang.thousands_sep, '')
407 .replace(lang.decimal_point, '.'))
409 class Duration(orm.AbstractModel):
410 _name = 'website.qweb.field.duration'
412 'ir.qweb.field.duration',
413 'website.qweb.field.float',
416 def attributes(self, cr, uid, field_name, record, options,
417 source_element, g_att, t_att, qweb_context,
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])])
424 def from_html(self, cr, uid, model, column, element, context=None):
425 value = element.text_content().strip()
427 # non-localized value
431 class RelativeDatetime(orm.AbstractModel):
432 _name = 'website.qweb.field.relative'
434 'ir.qweb.field.relative',
435 'website.qweb.field.datetime',
438 # get formatting from ir.qweb.field.relative but edition/save from datetime
441 class Contact(orm.AbstractModel):
442 _name = 'website.qweb.field.contact'
443 _inherit = ['ir.qweb.field.contact', 'website.qweb.field.many2one']
445 def from_html(self, cr, uid, model, column, element, context=None):
448 class QwebView(orm.AbstractModel):
449 _name = 'website.qweb.field.qweb'
450 _inherit = ['ir.qweb.field.qweb']
453 def html_to_text(element):
454 """ Converts HTML content with HTML-specified line breaks (br, p, div, ...)
455 in roughly equivalent textual content.
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
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
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
477 :param element: lxml.html content
478 :returns: corresponding pure-text output
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
485 _wrap(element, output)
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
491 r'[ \t\r\f]*\n[ \t\r\f]*',
493 ''.join(_realize_padding(output)).strip())
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
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'
502 def _collapse_whitespace(text):
503 """ Collapses sequences of whitespace characters in ``text`` to a single
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.
514 if isinstance(item, int):
515 padding = max(padding, item)
523 # leftover padding irrelevant as the output will be stripped
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``
529 :type wrapper: basestring | int
531 output.append(wrapper)
533 output.append(_collapse_whitespace(element.text))
534 for child in element:
535 _element_to_text(child, output)
536 output.append(wrapper)
538 def _element_to_text(e, output):
541 elif e.tag in _PADDED_BLOCK:
543 elif e.tag in _MISC_BLOCK:
550 output.append(_collapse_whitespace(e.tail))