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.
19 from dateutil import parser
20 from lxml import etree, html
21 from PIL import Image as I
22 import openerp.modules
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
30 REMOTE_CONNECTION_TIMEOUT = 2.5
32 logger = logging.getLogger(__name__)
34 class QWeb(orm.AbstractModel):
35 """ QWeb object for rendering stuff in the website context
37 _name = 'website.qweb'
45 def add_template(self, qcontext, name, node):
46 # preprocessing for multilang static urls
48 for tag, attr in self.URL_ATTRS.items():
49 for e in node.getElementsByTagName(tag):
50 url = e.getAttribute(attr)
52 e.setAttribute(attr, qcontext.get('url_for')(url))
53 super(QWeb, self).add_template(qcontext, name, node)
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)
58 if request.website and att == self.URL_ATTRS.get(element.nodeName) and isinstance(val, basestring):
59 val = qwebcontext.get('url_for')(val)
62 def get_converter_for(self, field_type):
64 'website.qweb.field.' + field_type,
65 self.pool['website.qweb.field'])
67 class Field(orm.AbstractModel):
68 _name = 'website.qweb.field'
69 _inherit = 'ir.qweb.field'
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)]
77 placeholder = options.get('placeholder') \
78 or source_element.getAttribute('placeholder') \
79 or getattr(column, 'placeholder', None)
81 attrs.append(('placeholder', placeholder))
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),
90 def value_from_string(self, value):
93 def from_html(self, cr, uid, model, column, element, context=None):
94 return self.value_from_string(element.text_content().strip())
96 def qweb_object(self):
97 return self.pool['website.qweb']
99 class Integer(orm.AbstractModel):
100 _name = 'website.qweb.field.integer'
101 _inherit = ['website.qweb.field']
103 value_from_string = int
105 class Float(orm.AbstractModel):
106 _name = 'website.qweb.field.float'
107 _inherit = ['website.qweb.field', 'ir.qweb.field.float']
109 def from_html(self, cr, uid, model, column, element, context=None):
110 lang = self.user_lang(cr, uid, context=context)
112 value = element.text_content().strip()
114 return float(value.replace(lang.thousands_sep, '')
115 .replace(lang.decimal_point, '.'))
118 def parse_fuzzy(in_format, value):
119 day_first = in_format.find('%d') < in_format.find('%m')
121 if '%y' in in_format:
122 year_first = in_format.find('%y') < in_format.find('%d')
124 year_first = in_format.find('%Y') < in_format.find('%d')
126 return parser.parse(value, dayfirst=day_first, yearfirst=year_first)
128 class Date(orm.AbstractModel):
129 _name = 'website.qweb.field.date'
130 _inherit = ['website.qweb.field', 'ir.qweb.field.date']
132 def attributes(self, cr, uid, field_name, record, options,
133 source_element, g_att, t_att, qweb_context,
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])])
140 def from_html(self, cr, uid, model, column, element, context=None):
141 value = element.text_content().strip()
142 if not value: return False
144 datetime.datetime.strptime(value, DEFAULT_SERVER_DATE_FORMAT)
147 class DateTime(orm.AbstractModel):
148 _name = 'website.qweb.field.datetime'
149 _inherit = ['website.qweb.field', 'ir.qweb.field.datetime']
151 def attributes(self, cr, uid, field_name, record, options,
152 source_element, g_att, t_att, qweb_context,
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)
160 value = fields.datetime.context_timestamp(
161 cr, uid, timestamp=value, context=context)
162 value = value.strftime(openerp.tools.DEFAULT_SERVER_DATETIME_FORMAT)
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)
171 def from_html(self, cr, uid, model, column, element, context=None):
172 value = element.text_content().strip()
173 if not value: return False
175 datetime.datetime.strptime(value, DEFAULT_SERVER_DATETIME_FORMAT)
178 class Text(orm.AbstractModel):
179 _name = 'website.qweb.field.text'
180 _inherit = ['website.qweb.field', 'ir.qweb.field.text']
182 def from_html(self, cr, uid, model, column, element, context=None):
183 return html_to_text(element)
185 class Selection(orm.AbstractModel):
186 _name = 'website.qweb.field.selection'
187 _inherit = ['website.qweb.field', 'ir.qweb.field.selection']
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):
198 raise ValueError(u"No value found for label %s in selection %s" % (
201 class ManyToOne(orm.AbstractModel):
202 _name = 'website.qweb.field.many2one'
203 _inherit = ['website.qweb.field', 'ir.qweb.field.many2one']
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)
214 # if anything blows up, just ignore it and bail
217 [obj] = Model.read(cr, uid, [id], [field])
219 (m2o_id, _) = obj[field]
220 # assume _rec_name and write directly to it
221 M2O.write(cr, uid, [m2o_id], {
225 logger.exception("Could not save %r to m2o field %s of model %s",
226 value, field, Model._name)
228 # not necessary, but might as well be explicit about it
231 class HTML(orm.AbstractModel):
232 _name = 'website.qweb.field.html'
233 _inherit = ['website.qweb.field', 'ir.qweb.field.html']
235 def from_html(self, cr, uid, model, column, element, context=None):
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)
243 class Image(orm.AbstractModel):
248 set as attribute on the generated <img> tag
250 _name = 'website.qweb.field.image'
251 _inherit = ['website.qweb.field', 'ir.qweb.field.image']
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 " \
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)
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()
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'])
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)),
277 field_name, record.id, '&'.join(size_params)))
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')
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']]
290 if self.local_url_re.match(url_object.path):
291 return self.load_local_url(url)
293 return self.load_remote_url(url)
295 def load_local_url(self, url):
296 match = self.local_url_re.match(urlparse.urlsplit(url).path)
298 rest = match.group('rest')
299 for sep in os.sep, os.altsep:
300 if sep and sep != '/':
301 rest.replace(sep, '/')
303 path = openerp.modules.get_module_resource(
304 match.group('module'), 'static', *(rest.split('/')))
310 with open(path, 'rb') as f:
311 # force complete image load to ensure it's valid image data
315 return f.read().encode('base64')
317 logger.exception("Failed to load local image %r", url)
320 def load_remote_url(self, url):
322 # should probably remove remote URLs entirely:
323 # * in fields, downloading them without blowing up the server is a
325 # * in views, may trigger mixed content warnings if HTTPS CMS
326 # linking to HTTP images
327 # implement drag & drop image upload to mitigate?
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
335 logger.exception("Failed to load remote image %r", url)
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')
344 class Monetary(orm.AbstractModel):
345 _name = 'website.qweb.field.monetary'
346 _inherit = ['website.qweb.field', 'ir.qweb.field.monetary']
348 def from_html(self, cr, uid, model, column, element, context=None):
349 lang = self.user_lang(cr, uid, context=context)
351 value = element.find('span').text.strip()
353 return float(value.replace(lang.thousands_sep, '')
354 .replace(lang.decimal_point, '.'))
356 class Duration(orm.AbstractModel):
357 _name = 'website.qweb.field.duration'
359 'ir.qweb.field.duration',
360 'website.qweb.field.float',
363 def attributes(self, cr, uid, field_name, record, options,
364 source_element, g_att, t_att, qweb_context,
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])])
371 def from_html(self, cr, uid, model, column, element, context=None):
372 value = element.text_content().strip()
374 # non-localized value
378 class RelativeDatetime(orm.AbstractModel):
379 _name = 'website.qweb.field.relative'
381 'ir.qweb.field.relative',
382 'website.qweb.field.datetime',
385 # get formatting from ir.qweb.field.relative but edition/save from datetime
388 class Contact(orm.AbstractModel):
389 _name = 'website.qweb.field.contact'
390 _inherit = ['ir.qweb.field.contact', 'website.qweb.field.many2one']
392 class QwebView(orm.AbstractModel):
393 _name = 'website.qweb.field.qweb'
394 _inherit = ['ir.qweb.field.qweb']
397 def html_to_text(element):
398 """ Converts HTML content with HTML-specified line breaks (br, p, div, ...)
399 in roughly equivalent textual content.
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
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
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
421 :param element: lxml.html content
422 :returns: corresponding pure-text output
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
429 _wrap(element, output)
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
435 r'[ \t\r\f]*\n[ \t\r\f]*',
437 ''.join(_realize_padding(output)).strip())
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
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'
446 def _collapse_whitespace(text):
447 """ Collapses sequences of whitespace characters in ``text`` to a single
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.
458 if isinstance(item, int):
459 padding = max(padding, item)
467 # leftover padding irrelevant as the output will be stripped
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``
473 :type wrapper: basestring | int
475 output.append(wrapper)
477 output.append(_collapse_whitespace(element.text))
478 for child in element:
479 _element_to_text(child, output)
480 output.append(wrapper)
482 def _element_to_text(e, output):
485 elif e.tag in _PADDED_BLOCK:
487 elif e.tag in _MISC_BLOCK:
494 output.append(_collapse_whitespace(e.tail))