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 def add_template(self, qcontext, name, node):
49 # preprocessing for multilang static urls
51 for tag, attr in self.URL_ATTRS.iteritems():
52 for e in node.iterdescendants(tag=tag):
55 e.set(attr, qcontext.get('url_for')(url))
56 super(QWeb, self).add_template(qcontext, name, node)
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)
67 def get_converter_for(self, field_type):
69 'website.qweb.field.' + field_type,
70 self.pool['website.qweb.field'])
72 class Field(orm.AbstractModel):
73 _name = 'website.qweb.field'
74 _inherit = 'ir.qweb.field'
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)]
82 placeholder = options.get('placeholder') \
83 or source_element.get('placeholder') \
84 or getattr(column, 'placeholder', None)
86 attrs.append(('placeholder', placeholder))
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),
95 def value_from_string(self, value):
98 def from_html(self, cr, uid, model, column, element, context=None):
99 return self.value_from_string(element.text_content().strip())
101 def qweb_object(self):
102 return self.pool['website.qweb']
104 class Integer(orm.AbstractModel):
105 _name = 'website.qweb.field.integer'
106 _inherit = ['website.qweb.field']
108 value_from_string = int
110 class Float(orm.AbstractModel):
111 _name = 'website.qweb.field.float'
112 _inherit = ['website.qweb.field', 'ir.qweb.field.float']
114 def from_html(self, cr, uid, model, column, element, context=None):
115 lang = self.user_lang(cr, uid, context=context)
117 value = element.text_content().strip()
119 return float(value.replace(lang.thousands_sep, '')
120 .replace(lang.decimal_point, '.'))
123 def parse_fuzzy(in_format, value):
124 day_first = in_format.find('%d') < in_format.find('%m')
126 if '%y' in in_format:
127 year_first = in_format.find('%y') < in_format.find('%d')
129 year_first = in_format.find('%Y') < in_format.find('%d')
131 return parser.parse(value, dayfirst=day_first, yearfirst=year_first)
133 class Date(orm.AbstractModel):
134 _name = 'website.qweb.field.date'
135 _inherit = ['website.qweb.field', 'ir.qweb.field.date']
137 def attributes(self, cr, uid, field_name, record, options,
138 source_element, g_att, t_att, qweb_context,
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])])
145 def from_html(self, cr, uid, model, column, element, context=None):
146 value = element.text_content().strip()
147 if not value: return False
149 datetime.datetime.strptime(value, DEFAULT_SERVER_DATE_FORMAT)
152 class DateTime(orm.AbstractModel):
153 _name = 'website.qweb.field.datetime'
154 _inherit = ['website.qweb.field', 'ir.qweb.field.datetime']
156 def attributes(self, cr, uid, field_name, record, options,
157 source_element, g_att, t_att, qweb_context,
159 value = record[field_name]
160 if isinstance(value, basestring):
161 value = datetime.datetime.strptime(
162 value, DEFAULT_SERVER_DATETIME_FORMAT)
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)
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)
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
181 # parse from string to datetime
182 dt = datetime.datetime.strptime(value, DEFAULT_SERVER_DATETIME_FORMAT)
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']
189 user_tz = pytz.timezone(tz_name)
192 dt = user_tz.localize(dt).astimezone(utc)
195 "Failed to convert the value for a field of the model"
196 " %s back from the user's timezone (%s) to UTC",
200 # format back to string
201 return dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT)
203 class Text(orm.AbstractModel):
204 _name = 'website.qweb.field.text'
205 _inherit = ['website.qweb.field', 'ir.qweb.field.text']
207 def from_html(self, cr, uid, model, column, element, context=None):
208 return html_to_text(element)
210 class Selection(orm.AbstractModel):
211 _name = 'website.qweb.field.selection'
212 _inherit = ['website.qweb.field', 'ir.qweb.field.selection']
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):
223 raise ValueError(u"No value found for label %s in selection %s" % (
226 class ManyToOne(orm.AbstractModel):
227 _name = 'website.qweb.field.many2one'
228 _inherit = ['website.qweb.field', 'ir.qweb.field.many2one']
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)
239 # if anything blows up, just ignore it and bail
242 [obj] = Model.read(cr, uid, [id], [field])
244 (m2o_id, _) = obj[field]
245 # assume _rec_name and write directly to it
246 M2O.write(cr, uid, [m2o_id], {
250 logger.exception("Could not save %r to m2o field %s of model %s",
251 value, field, Model._name)
253 # not necessary, but might as well be explicit about it
256 class HTML(orm.AbstractModel):
257 _name = 'website.qweb.field.html'
258 _inherit = ['website.qweb.field', 'ir.qweb.field.html']
260 def from_html(self, cr, uid, model, column, element, context=None):
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)
268 class Image(orm.AbstractModel):
273 set as attribute on the generated <img> tag
275 _name = 'website.qweb.field.image'
276 _inherit = ['website.qweb.field', 'ir.qweb.field.image']
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 " \
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)
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))
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)
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)
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')
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)
318 if self.local_url_re.match(url_object.path):
319 return self.load_local_url(url)
321 return self.load_remote_url(url)
323 def load_local_url(self, url):
324 match = self.local_url_re.match(urlparse.urlsplit(url).path)
326 rest = match.group('rest')
327 for sep in os.sep, os.altsep:
328 if sep and sep != '/':
329 rest.replace(sep, '/')
331 path = openerp.modules.get_module_resource(
332 match.group('module'), 'static', *(rest.split('/')))
338 with open(path, 'rb') as f:
339 # force complete image load to ensure it's valid image data
343 return f.read().encode('base64')
345 logger.exception("Failed to load local image %r", url)
348 def load_remote_url(self, url):
350 # should probably remove remote URLs entirely:
351 # * in fields, downloading them without blowing up the server is a
353 # * in views, may trigger mixed content warnings if HTTPS CMS
354 # linking to HTTP images
355 # implement drag & drop image upload to mitigate?
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
363 logger.exception("Failed to load remote image %r", url)
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')
372 class Monetary(orm.AbstractModel):
373 _name = 'website.qweb.field.monetary'
374 _inherit = ['website.qweb.field', 'ir.qweb.field.monetary']
376 def from_html(self, cr, uid, model, column, element, context=None):
377 lang = self.user_lang(cr, uid, context=context)
379 value = element.find('span').text.strip()
381 return float(value.replace(lang.thousands_sep, '')
382 .replace(lang.decimal_point, '.'))
384 class Duration(orm.AbstractModel):
385 _name = 'website.qweb.field.duration'
387 'ir.qweb.field.duration',
388 'website.qweb.field.float',
391 def attributes(self, cr, uid, field_name, record, options,
392 source_element, g_att, t_att, qweb_context,
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])])
399 def from_html(self, cr, uid, model, column, element, context=None):
400 value = element.text_content().strip()
402 # non-localized value
406 class RelativeDatetime(orm.AbstractModel):
407 _name = 'website.qweb.field.relative'
409 'ir.qweb.field.relative',
410 'website.qweb.field.datetime',
413 # get formatting from ir.qweb.field.relative but edition/save from datetime
416 class Contact(orm.AbstractModel):
417 _name = 'website.qweb.field.contact'
418 _inherit = ['ir.qweb.field.contact', 'website.qweb.field.many2one']
420 def from_html(self, cr, uid, model, column, element, context=None):
423 class QwebView(orm.AbstractModel):
424 _name = 'website.qweb.field.qweb'
425 _inherit = ['ir.qweb.field.qweb']
428 def html_to_text(element):
429 """ Converts HTML content with HTML-specified line breaks (br, p, div, ...)
430 in roughly equivalent textual content.
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
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
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
452 :param element: lxml.html content
453 :returns: corresponding pure-text output
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
460 _wrap(element, output)
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
466 r'[ \t\r\f]*\n[ \t\r\f]*',
468 ''.join(_realize_padding(output)).strip())
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
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'
477 def _collapse_whitespace(text):
478 """ Collapses sequences of whitespace characters in ``text`` to a single
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.
489 if isinstance(item, int):
490 padding = max(padding, item)
498 # leftover padding irrelevant as the output will be stripped
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``
504 :type wrapper: basestring | int
506 output.append(wrapper)
508 output.append(_collapse_whitespace(element.text))
509 for child in element:
510 _element_to_text(child, output)
511 output.append(wrapper)
513 def _element_to_text(e, output):
516 elif e.tag in _PADDED_BLOCK:
518 elif e.tag in _MISC_BLOCK:
525 output.append(_collapse_whitespace(e.tail))