summaryrefslogtreecommitdiff
path: root/addons/web_editor/models
diff options
context:
space:
mode:
authorChristophe Matthieu <chm@odoo.com>2015-07-07 16:02:53 +0200
committerChristophe Matthieu <chm@odoo.com>2015-07-10 17:00:11 +0200
commit8c559044a5e1be111dcb577ad7e5ab462c1002a0 (patch)
treeba1602e15b2bca38babd6cca63a41da1d05b4321 /addons/web_editor/models
parenta616c57c609da0e5e56835dcb45e1c05590d25b8 (diff)
[IMP] web_editor: split website into web_editor (to use editor in backend for html field) and website
Diffstat (limited to 'addons/web_editor/models')
-rw-r--r--addons/web_editor/models/__init__.py7
-rw-r--r--addons/web_editor/models/ir_attachment.py149
-rw-r--r--addons/web_editor/models/ir_http.py16
-rw-r--r--addons/web_editor/models/ir_qweb.py566
-rw-r--r--addons/web_editor/models/ir_ui_view.py123
-rw-r--r--addons/web_editor/models/test_models.py43
6 files changed, 904 insertions, 0 deletions
diff --git a/addons/web_editor/models/__init__.py b/addons/web_editor/models/__init__.py
new file mode 100644
index 00000000000..43522002bfc
--- /dev/null
+++ b/addons/web_editor/models/__init__.py
@@ -0,0 +1,7 @@
+# -*- coding: utf-8 -*-
+import ir_attachment
+import ir_qweb
+import ir_ui_view
+import ir_http
+
+import test_models
diff --git a/addons/web_editor/models/ir_attachment.py b/addons/web_editor/models/ir_attachment.py
new file mode 100644
index 00000000000..3068807b2ab
--- /dev/null
+++ b/addons/web_editor/models/ir_attachment.py
@@ -0,0 +1,149 @@
+# -*- coding: utf-8 -*-
+import openerp
+from openerp.osv import osv, fields
+
+import contextlib
+from sys import maxint
+from openerp.addons.web.http import request
+import datetime
+import hashlib
+import time
+import os
+
+from openerp.tools import html_escape as escape, ustr, image_resize_and_sharpen, image_save_for_web
+from PIL import Image
+import cStringIO
+
+import logging
+logger = logging.getLogger(__name__)
+
+
+class ir_attachment(osv.osv):
+
+ _inherit = "ir.attachment"
+
+ def _local_url_get(self, cr, uid, ids, name, arg, context=None):
+ result = {}
+ for attach in self.browse(cr, uid, ids, context=context):
+ if attach.url:
+ result[attach.id] = attach.url
+ else:
+ result[attach.id] = self.image_url(cr, uid, attach, 'datas')
+ return result
+
+ _columns = {
+ 'local_url': fields.function(_local_url_get, string="Attachment URL", type='char'),
+ }
+
+ def _image_placeholder(self, response):
+ # file_open may return a StringIO. StringIO can be closed but are
+ # not context managers in Python 2 though that is fixed in 3
+ with contextlib.closing(openerp.tools.misc.file_open(
+ os.path.join('web', 'static', 'src', 'img', 'placeholder.png'),
+ mode='rb')) as f:
+ response.data = f.read()
+ return response.make_conditional(request.httprequest)
+
+ def _image(self, cr, uid, model, id_or_ids, field, response, max_width=maxint, max_height=maxint, cache=None, context=None):
+ """ Fetches the requested field and ensures it does not go above
+ (max_width, max_height), resizing it if necessary.
+
+ Resizing is bypassed if the object provides a $field_big, which will
+ be interpreted as a pre-resized version of the base field.
+
+ If the record is not found or does not have the requested field,
+ returns a placeholder image via :meth:`~._image_placeholder`.
+
+ Sets and checks conditional response parameters:
+ * :mailheader:`ETag` is always set (and checked)
+ * :mailheader:`Last-Modified is set iif the record has a concurrency
+ field (``__last_update``)
+
+ The requested field is assumed to be base64-encoded image data in
+ all cases.
+ """
+ Model = self.pool[model]
+ ids = isinstance(id_or_ids, (list, tuple)) and id_or_ids or [int(id_or_ids)]
+ ids = Model.search(cr, uid, [('id', 'in', ids)], context=context)
+
+ if not ids:
+ return self._image_placeholder(response)
+
+ concurrency = '__last_update'
+ [record] = Model.read(cr, openerp.SUPERUSER_ID, ids, [concurrency, field], context=context)
+
+ if concurrency in record:
+ server_format = openerp.tools.misc.DEFAULT_SERVER_DATETIME_FORMAT
+ try:
+ response.last_modified = datetime.datetime.strptime(
+ record[concurrency], server_format + '.%f')
+ except ValueError:
+ # just in case we have a timestamp without microseconds
+ response.last_modified = datetime.datetime.strptime(
+ record[concurrency], server_format)
+
+ # Field does not exist on model or field set to False
+ if not record.get(field):
+ # FIXME: maybe a field which does not exist should be a 404?
+ return self._image_placeholder(response)
+
+ response.set_etag(hashlib.sha1(record[field]).hexdigest())
+ response.make_conditional(request.httprequest)
+
+ if cache:
+ response.cache_control.max_age = cache
+ response.expires = int(time.time() + cache)
+
+ # conditional request match
+ if response.status_code == 304:
+ return response
+
+ if model == 'ir.attachment' and field == 'url' and field in record:
+ path = record[field].strip('/')
+
+ # Check that we serve a file from within the module
+ if os.path.normpath(path).startswith('..'):
+ return self._image_placeholder(response)
+
+ # Check that the file actually exists
+ path = path.split('/')
+ resource = openerp.modules.get_module_resource(*path)
+ if not resource:
+ return self._image_placeholder(response)
+
+ data = open(resource, 'rb').read()
+ else:
+ data = record[field].decode('base64')
+ image = Image.open(cStringIO.StringIO(data))
+ response.mimetype = Image.MIME[image.format]
+
+ filename = '%s_%s.%s' % (model.replace('.', '_'), ids[0], str(image.format).lower())
+ response.headers['Content-Disposition'] = 'inline; filename="%s"' % filename
+
+ if (not max_width) and (not max_height):
+ response.data = data
+ return response
+
+ w, h = image.size
+ max_w = int(max_width) if max_width else maxint
+ max_h = int(max_height) if max_height else maxint
+
+ if w < max_w and h < max_h:
+ response.data = data
+ else:
+ size = (max_w, max_h)
+ img = image_resize_and_sharpen(image, size, preserve_aspect_ratio=True)
+ image_save_for_web(img, response.stream, format=image.format)
+ # invalidate content-length computed by make_conditional as
+ # writing to response.stream does not do it (as of werkzeug 0.9.3)
+ del response.headers['Content-Length']
+
+ return response
+
+ def image_url(self, cr, uid, record, field, size=None, context=None):
+ """Returns a local url that points to the image field of a given browse record."""
+ model = record._name
+ sudo_record = record.sudo()
+ id = '%s_%s' % (record.id, hashlib.sha1(sudo_record.write_date or sudo_record.create_date or '').hexdigest()[0:7])
+ size = '' if size is None else '/%s' % size
+ return '/web_editor/image/%s/%s/%s%s' % (model, id, field, size)
diff --git a/addons/web_editor/models/ir_http.py b/addons/web_editor/models/ir_http.py
new file mode 100644
index 00000000000..c72fee55e83
--- /dev/null
+++ b/addons/web_editor/models/ir_http.py
@@ -0,0 +1,16 @@
+# -*- coding: utf-8 -*-
+from openerp.osv import orm
+from openerp.http import request
+
+
+class ir_http(orm.AbstractModel):
+ _inherit = 'ir.http'
+
+ def _dispatch(self):
+ if 'editable' in request.httprequest.args and 'editable' not in request.context:
+ request.context['editable'] = True
+ if 'edit_translations' in request.httprequest.args and 'edit_translations' not in request.context:
+ request.context['edit_translations'] = True
+ if request.context.get('lang') != "en_US" and 'translatable' not in request.context:
+ request.context['translatable'] = True
+ return super(ir_http, self)._dispatch()
diff --git a/addons/web_editor/models/ir_qweb.py b/addons/web_editor/models/ir_qweb.py
new file mode 100644
index 00000000000..9642a3c2881
--- /dev/null
+++ b/addons/web_editor/models/ir_qweb.py
@@ -0,0 +1,566 @@
+# -*- coding: utf-8 -*-
+"""
+Web_editor-context rendering needs to add some metadata to rendered and allow to edit fields,
+as well as render a few fields differently.
+
+Also, adds methods to convert values back to openerp models.
+"""
+
+import cStringIO
+import datetime
+import itertools
+import logging
+import os
+import urllib2
+import urlparse
+import re
+import simplejson
+
+import pytz
+from dateutil import parser
+from lxml import etree, html
+from PIL import Image as I
+import openerp.modules
+
+import openerp
+from openerp.osv import orm, fields
+from openerp.tools import ustr, DEFAULT_SERVER_DATE_FORMAT, DEFAULT_SERVER_DATETIME_FORMAT
+from openerp.tools import html_escape as escape
+from openerp.addons.base.ir import ir_qweb
+
+REMOTE_CONNECTION_TIMEOUT = 2.5
+
+logger = logging.getLogger(__name__)
+
+
+class QWeb(orm.AbstractModel):
+ """ QWeb object for rendering editor stuff
+ """
+ _name = 'ir.qweb'
+ _inherit = 'ir.qweb'
+
+ re_remove_spaces = re.compile('\s+')
+
+ def render_tag_snippet(self, element, template_attributes, generated_attributes, qwebcontext):
+ cr = qwebcontext['request'].cr
+ uid = qwebcontext['request'].uid
+ view_reg = self.pool['ir.ui.view']
+ page_xmlid = self.eval_format(template_attributes["snippet"], qwebcontext)
+ view = view_reg.browse(cr, uid, view_reg.search(cr, uid, [('key', '=', page_xmlid)]))
+ generated_attributes += " " + "".join([
+ 'data-oe-name="%s"' % view.name,
+ 'data-oe-type="snippet"',
+ 'data-oe-thumbnail="%s"' % template_attributes.get('thumbnail', "oe-thumbnail")])
+
+ template_attributes['call'] = template_attributes['snippet']
+ return self.render_tag_call(element, template_attributes, generated_attributes, qwebcontext)
+
+ def get_converter_for(self, field_type):
+ return self.pool.get('ir.qweb.field.' + field_type, self.pool['ir.qweb.field'])
+
+
+#------------------------------------------------------
+# QWeb fields
+#------------------------------------------------------
+
+
+class Field(orm.AbstractModel):
+ _name = 'ir.qweb.field'
+ _inherit = 'ir.qweb.field'
+
+ def attributes(self, cr, uid, field_name, record, options,
+ source_element, g_att, t_att, qweb_context, context=None):
+ if options is None:
+ options = {}
+ field = record._model._fields[field_name]
+ attrs = []
+
+ placeholder = options.get('placeholder') \
+ or source_element.get('placeholder') \
+ or getattr(field, 'placeholder', None)
+ if placeholder:
+ attrs.append(('placeholder', placeholder))
+
+ return itertools.chain(
+ super(Field, self).attributes(cr, uid, field_name, record, options,
+ source_element, g_att, t_att,
+ qweb_context, context=context),
+ attrs
+ )
+
+ def value_from_string(self, value):
+ return value
+
+ def from_html(self, cr, uid, model, field, element, context=None):
+ return self.value_from_string(element.text_content().strip())
+
+ def qweb_object(self):
+ return self.pool['ir.qweb']
+
+
+class Integer(orm.AbstractModel):
+ _name = 'ir.qweb.field.integer'
+ _inherit = ['ir.qweb.field']
+
+ value_from_string = int
+
+
+class Float(orm.AbstractModel):
+ _name = 'ir.qweb.field.float'
+ _inherit = ['ir.qweb.field', 'ir.qweb.field.float']
+
+ def from_html(self, cr, uid, model, field, element, context=None):
+ lang = self.user_lang(cr, uid, context=context)
+
+ value = element.text_content().strip()
+
+ return float(value.replace(lang.thousands_sep, '')
+ .replace(lang.decimal_point, '.'))
+
+
+class ManyToOne(orm.AbstractModel):
+ _name = 'ir.qweb.field.many2one'
+ _inherit = ['ir.qweb.field.many2one']
+
+ def attributes(self, cr, uid, field_name, record, options,
+ source_element, g_att, t_att, qweb_context,
+ context=None):
+ attrs = super(ManyToOne, self).attributes(
+ cr, uid, field_name, record, options, source_element, g_att, t_att,
+ qweb_context, context=context)
+ many2one = getattr(record, field_name)
+ if many2one:
+ data = [('data-oe-many2one-id', many2one.id),
+ ('data-oe-many2one-model', many2one._name)]
+ return itertools.chain(attrs, data)
+ else:
+ return attrs
+
+ def from_html(self, cr, uid, model, field, element, context=None):
+ Model = self.pool[element.get('data-oe-model')]
+ M2O = self.pool[field.comodel_name]
+ field_name = element.get('data-oe-field')
+ id = int(element.get('data-oe-id'))
+ many2one_id = int(element.get('data-oe-many2one-id'))
+
+ if many2one_id and M2O.exists(cr, uid, [many2one_id]):
+ # save the new id of the many2one
+ Model.write(cr, uid, [id], {
+ field_name: many2one_id
+ }, context=context)
+
+ # not necessary, but might as well be explicit about it
+ return None
+
+
+class Contact(orm.AbstractModel):
+ _name = 'ir.qweb.field.contact'
+ _inherit = ['ir.qweb.field.contact', 'ir.qweb.field.many2one']
+
+ def attributes(self, cr, uid, field_name, record, options,
+ source_element, g_att, t_att, qweb_context,
+ context=None):
+ attrs = super(Contact, self).attributes(
+ cr, uid, field_name, record, options, source_element, g_att, t_att,
+ qweb_context, context=context)
+ if getattr(record, field_name):
+ return itertools.chain(attrs, [('data-oe-contact-options', simplejson.dumps(options))])
+ else:
+ return attrs
+
+ # helper to call the rendering of contact field
+ def get_record_to_html(self, cr, uid, ids, options=None, context=None):
+ node = self.record_to_html(cr, uid, 'record', {
+ 'record': self.pool['res.partner'].browse(cr, uid, ids[0], context=context)},
+ options=options, context=context)
+ return node and node.__html__()
+
+
+def parse_fuzzy(in_format, value):
+ day_first = in_format.find('%d') < in_format.find('%m')
+
+ if '%y' in in_format:
+ year_first = in_format.find('%y') < in_format.find('%d')
+ else:
+ year_first = in_format.find('%Y') < in_format.find('%d')
+
+ return parser.parse(value, dayfirst=day_first, yearfirst=year_first)
+
+
+class Date(orm.AbstractModel):
+ _name = 'ir.qweb.field.date'
+ _inherit = ['ir.qweb.field', 'ir.qweb.field.date']
+
+ def attributes(self, cr, uid, field_name, record, options,
+ source_element, g_att, t_att, qweb_context,
+ context=None):
+ attrs = super(Date, self).attributes(
+ cr, uid, field_name, record, options, source_element, g_att, t_att,
+ qweb_context, context=None)
+ return itertools.chain(attrs, [('data-oe-original', record[field_name])])
+
+ def from_html(self, cr, uid, model, field, element, context=None):
+ value = element.text_content().strip()
+ if not value:
+ return False
+
+ datetime.datetime.strptime(value, DEFAULT_SERVER_DATE_FORMAT)
+ return value
+
+
+class DateTime(orm.AbstractModel):
+ _name = 'ir.qweb.field.datetime'
+ _inherit = ['ir.qweb.field', 'ir.qweb.field.datetime']
+
+ def attributes(self, cr, uid, field_name, record, options,
+ source_element, g_att, t_att, qweb_context,
+ context=None):
+ value = record[field_name]
+ if isinstance(value, basestring):
+ value = datetime.datetime.strptime(
+ value, DEFAULT_SERVER_DATETIME_FORMAT)
+ if value:
+ # convert from UTC (server timezone) to user timezone
+ value = fields.datetime.context_timestamp(
+ cr, uid, timestamp=value, context=context)
+ value = value.strftime(DEFAULT_SERVER_DATETIME_FORMAT)
+
+ attrs = super(DateTime, self).attributes(
+ cr, uid, field_name, record, options, source_element, g_att, t_att,
+ qweb_context, context=None)
+ return itertools.chain(attrs, [
+ ('data-oe-original', value)
+ ])
+
+ def from_html(self, cr, uid, model, field, element, context=None):
+ if context is None:
+ context = {}
+ value = element.text_content().strip()
+ if not value:
+ return False
+
+ # parse from string to datetime
+ dt = datetime.datetime.strptime(value, DEFAULT_SERVER_DATETIME_FORMAT)
+
+ # convert back from user's timezone to UTC
+ tz_name = context.get('tz') \
+ or self.pool['res.users'].read(cr, openerp.SUPERUSER_ID, uid, ['tz'], context=context)['tz']
+ if tz_name:
+ try:
+ user_tz = pytz.timezone(tz_name)
+ utc = pytz.utc
+
+ dt = user_tz.localize(dt).astimezone(utc)
+ except Exception:
+ logger.warn(
+ "Failed to convert the value for a field of the model"
+ " %s back from the user's timezone (%s) to UTC",
+ model, tz_name,
+ exc_info=True)
+
+ # format back to string
+ return dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT)
+
+
+class Text(orm.AbstractModel):
+ _name = 'ir.qweb.field.text'
+ _inherit = ['ir.qweb.field', 'ir.qweb.field.text']
+
+ def from_html(self, cr, uid, model, field, element, context=None):
+ return html_to_text(element)
+
+
+class Selection(orm.AbstractModel):
+ _name = 'ir.qweb.field.selection'
+ _inherit = ['ir.qweb.field', 'ir.qweb.field.selection']
+
+ def from_html(self, cr, uid, model, field, element, context=None):
+ record = self.browse(cr, uid, [], context=context)
+ value = element.text_content().strip()
+ selection = field.get_description(record.env)['selection']
+ for k, v in selection:
+ if isinstance(v, str):
+ v = ustr(v)
+ if value == v:
+ return k
+
+ raise ValueError(u"No value found for label %s in selection %s" % (
+ value, selection))
+
+
+class HTML(orm.AbstractModel):
+ _name = 'ir.qweb.field.html'
+ _inherit = ['ir.qweb.field', 'ir.qweb.field.html']
+
+ def from_html(self, cr, uid, model, field, element, context=None):
+ content = []
+ if element.text:
+ content.append(element.text)
+ content.extend(html.tostring(child)
+ for child in element.iterchildren(tag=etree.Element))
+ return '\n'.join(content)
+
+
+class Image(orm.AbstractModel):
+ """
+ Widget options:
+
+ ``class``
+ set as attribute on the generated <img> tag
+ """
+ _name = 'ir.qweb.field.image'
+ _inherit = ['ir.qweb.field', 'ir.qweb.field.image']
+
+ def to_html(self, cr, uid, field_name, record, options,
+ source_element, t_att, g_att, qweb_context, context=None):
+ assert source_element.tag != 'img',\
+ "Oddly enough, the root tag of an image field can not be img. " \
+ "That is because the image goes into the tag, or it gets the " \
+ "hose again."
+
+ return super(Image, self).to_html(
+ cr, uid, field_name, record, options,
+ source_element, t_att, g_att, qweb_context, context=context)
+
+ def record_to_html(self, cr, uid, field_name, record, options=None, context=None):
+ if options is None:
+ options = {}
+ aclasses = ['img', 'img-responsive'] + options.get('class', '').split()
+ classes = ' '.join(itertools.imap(escape, aclasses))
+
+ max_size = None
+ max_width, max_height = options.get('max_width', 0), options.get('max_height', 0)
+ if max_width or max_height:
+ max_size = '%sx%s' % (max_width, max_height)
+
+ src = self.pool['ir.attachment'].image_url(cr, uid, record, field_name, max_size)
+ if options.get('resize'):
+ src = "%s/%s" % (src, options.get('resize'))
+
+ img = '<img class="%s" src="%s" style="%s"/>' % (classes, src, options.get('style', ''))
+ return ir_qweb.HTMLSafe(img)
+
+ local_url_re = re.compile(r'^/(?P<module>[^]]+)/static/(?P<rest>.+)$')
+
+ def from_html(self, cr, uid, model, field, element, context=None):
+ url = element.find('img').get('src')
+
+ url_object = urlparse.urlsplit(url)
+ if url_object.path.startswith('/web_editor/image'):
+ # url might be /web_editor/image/<model>/<id>[_<checksum>]/<field>[/<width>x<height>]
+ fragments = url_object.path.split('/')
+ query = dict(urlparse.parse_qsl(url_object.query))
+ model = query.get('model', fragments[3])
+ oid = query.get('id', fragments[4].split('_')[0])
+ field = query.get('field', fragments[5])
+ item = self.pool[model].browse(cr, uid, int(oid), context=context)
+ return item[field]
+
+ if self.local_url_re.match(url_object.path):
+ return self.load_local_url(url)
+
+ return self.load_remote_url(url)
+
+ def load_local_url(self, url):
+ match = self.local_url_re.match(urlparse.urlsplit(url).path)
+
+ rest = match.group('rest')
+ for sep in os.sep, os.altsep:
+ if sep and sep != '/':
+ rest.replace(sep, '/')
+
+ path = openerp.modules.get_module_resource(
+ match.group('module'), 'static', *(rest.split('/')))
+
+ if not path:
+ return None
+
+ try:
+ with open(path, 'rb') as f:
+ # force complete image load to ensure it's valid image data
+ image = I.open(f)
+ image.load()
+ f.seek(0)
+ return f.read().encode('base64')
+ except Exception:
+ logger.exception("Failed to load local image %r", url)
+ return None
+
+ def load_remote_url(self, url):
+ try:
+ # should probably remove remote URLs entirely:
+ # * in fields, downloading them without blowing up the server is a
+ # challenge
+ # * in views, may trigger mixed content warnings if HTTPS CMS
+ # linking to HTTP images
+ # implement drag & drop image upload to mitigate?
+
+ req = urllib2.urlopen(url, timeout=REMOTE_CONNECTION_TIMEOUT)
+ # PIL needs a seekable file-like image, urllib result is not seekable
+ image = I.open(cStringIO.StringIO(req.read()))
+ # force a complete load of the image data to validate it
+ image.load()
+ except Exception:
+ logger.exception("Failed to load remote image %r", url)
+ return None
+
+ # don't use original data in case weird stuff was smuggled in, with
+ # luck PIL will remove some of it?
+ out = cStringIO.StringIO()
+ image.save(out, image.format)
+ return out.getvalue().encode('base64')
+
+
+class Monetary(orm.AbstractModel):
+ _name = 'ir.qweb.field.monetary'
+ _inherit = ['ir.qweb.field', 'ir.qweb.field.monetary']
+
+ def from_html(self, cr, uid, model, field, element, context=None):
+ lang = self.user_lang(cr, uid, context=context)
+
+ value = element.find('span').text.strip()
+
+ return float(value.replace(lang.thousands_sep, '')
+ .replace(lang.decimal_point, '.'))
+
+
+class Duration(orm.AbstractModel):
+ _name = 'ir.qweb.field.duration'
+ _inherit = [
+ 'ir.qweb.field.duration',
+ 'ir.qweb.field.float',
+ ]
+
+ def attributes(self, cr, uid, field_name, record, options,
+ source_element, g_att, t_att, qweb_context,
+ context=None):
+ attrs = super(Duration, self).attributes(
+ cr, uid, field_name, record, options, source_element, g_att, t_att,
+ qweb_context, context=None)
+ return itertools.chain(attrs, [('data-oe-original', record[field_name])])
+
+ def from_html(self, cr, uid, model, field, element, context=None):
+ value = element.text_content().strip()
+
+ # non-localized value
+ return float(value)
+
+
+class RelativeDatetime(orm.AbstractModel):
+ _name = 'ir.qweb.field.relative'
+ _inherit = [
+ 'ir.qweb.field.relative',
+ 'ir.qweb.field.datetime',
+ ]
+
+ # get formatting from ir.qweb.field.relative but edition/save from datetime
+
+
+class QwebView(orm.AbstractModel):
+ _name = 'ir.qweb.field.qweb'
+ _inherit = ['ir.qweb.field.qweb']
+
+
+def html_to_text(element):
+ """ Converts HTML content with HTML-specified line breaks (br, p, div, ...)
+ in roughly equivalent textual content.
+
+ Used to replace and fixup the roundtripping of text and m2o: when using
+ libxml 2.8.0 (but not 2.9.1) and parsing HTML with lxml.html.fromstring
+ whitespace text nodes (text nodes composed *solely* of whitespace) are
+ stripped out with no recourse, and fundamentally relying on newlines
+ being in the text (e.g. inserted during user edition) is probably poor form
+ anyway.
+
+ -> this utility function collapses whitespace sequences and replaces
+ nodes by roughly corresponding linebreaks
+ * p are pre-and post-fixed by 2 newlines
+ * br are replaced by a single newline
+ * block-level elements not already mentioned are pre- and post-fixed by
+ a single newline
+
+ ought be somewhat similar (but much less high-tech) to aaronsw's html2text.
+ the latter produces full-blown markdown, our text -> html converter only
+ replaces newlines by <br> elements at this point so we're reverting that,
+ and a few more newline-ish elements in case the user tried to add
+ newlines/paragraphs into the text field
+
+ :param element: lxml.html content
+ :returns: corresponding pure-text output
+ """
+
+ # output is a list of str | int. Integers are padding requests (in minimum
+ # number of newlines). When multiple padding requests, fold them into the
+ # biggest one
+ output = []
+ _wrap(element, output)
+
+ # remove any leading or tailing whitespace, replace sequences of
+ # (whitespace)\n(whitespace) by a single newline, where (whitespace) is a
+ # non-newline whitespace in this case
+ return re.sub(
+ r'[ \t\r\f]*\n[ \t\r\f]*',
+ '\n',
+ ''.join(_realize_padding(output)).strip())
+
+_PADDED_BLOCK = set('p h1 h2 h3 h4 h5 h6'.split())
+# https://developer.mozilla.org/en-US/docs/HTML/Block-level_elements minus p
+_MISC_BLOCK = set((
+ 'address article aside audio blockquote canvas dd dl div figcaption figure'
+ ' footer form header hgroup hr ol output pre section tfoot ul video'
+).split())
+
+
+def _collapse_whitespace(text):
+ """ Collapses sequences of whitespace characters in ``text`` to a single
+ space
+ """
+ return re.sub('\s+', ' ', text)
+
+
+def _realize_padding(it):
+ """ Fold and convert padding requests: integers in the output sequence are
+ requests for at least n newlines of padding. Runs thereof can be collapsed
+ into the largest requests and converted to newlines.
+ """
+ padding = None
+ for item in it:
+ if isinstance(item, int):
+ padding = max(padding, item)
+ continue
+
+ if padding:
+ yield '\n' * padding
+ padding = None
+
+ yield item
+ # leftover padding irrelevant as the output will be stripped
+
+
+def _wrap(element, output, wrapper=u''):
+ """ Recursively extracts text from ``element`` (via _element_to_text), and
+ wraps it all in ``wrapper``. Extracted text is added to ``output``
+
+ :type wrapper: basestring | int
+ """
+ output.append(wrapper)
+ if element.text:
+ output.append(_collapse_whitespace(element.text))
+ for child in element:
+ _element_to_text(child, output)
+ output.append(wrapper)
+
+
+def _element_to_text(e, output):
+ if e.tag == 'br':
+ output.append(u'\n')
+ elif e.tag in _PADDED_BLOCK:
+ _wrap(e, output, 2)
+ elif e.tag in _MISC_BLOCK:
+ _wrap(e, output, 1)
+ else:
+ # inline
+ _wrap(e, output)
+
+ if e.tail:
+ output.append(_collapse_whitespace(e.tail))
diff --git a/addons/web_editor/models/ir_ui_view.py b/addons/web_editor/models/ir_ui_view.py
new file mode 100644
index 00000000000..5946e7128bb
--- /dev/null
+++ b/addons/web_editor/models/ir_ui_view.py
@@ -0,0 +1,123 @@
+# -*- coding: utf-8 -*-
+import copy
+import openerp
+from openerp.exceptions import AccessError
+from openerp.osv import osv
+from lxml import etree, html
+
+
+class view(osv.osv):
+ _inherit = 'ir.ui.view'
+
+ def render(self, cr, uid, id_or_xml_id, values=None, engine='ir.qweb', context=None):
+ if not values:
+ values = {}
+ if values.get('editable'):
+ try:
+ if not isinstance(id_or_xml_id, (int, long)):
+ if '.' not in id_or_xml_id:
+ raise ValueError('Invalid template id: %r' % (id_or_xml_id,))
+ id_or_xml_id = self.get_view_id(cr, uid, id_or_xml_id, context=context)
+
+ self.check_access_rule(cr, uid, [id_or_xml_id], 'write', context=context)
+
+ except AccessError:
+ values['editable'] = False
+
+ return super(view, self).render(cr, uid, id_or_xml_id, values=values, engine=engine, context=context)
+
+ #------------------------------------------------------
+ # Save from html
+ #------------------------------------------------------
+
+ def extract_embedded_fields(self, cr, uid, arch, context=None):
+ return arch.xpath('//*[@data-oe-model != "ir.ui.view"]')
+
+ def save_embedded_field(self, cr, uid, el, context=None):
+ Model = self.pool[el.get('data-oe-model')]
+ field = el.get('data-oe-field')
+
+ converter = self.pool['ir.qweb'].get_converter_for(el.get('data-oe-type'))
+ value = converter.from_html(cr, uid, Model, Model._fields[field], el)
+
+ if value is not None:
+ # TODO: batch writes?
+ Model.write(cr, uid, [int(el.get('data-oe-id'))], {
+ field: value
+ }, context=context)
+
+ def _pretty_arch(self, arch):
+ # remove_blank_string does not seem to work on HTMLParser, and
+ # pretty-printing with lxml more or less requires stripping
+ # whitespace: http://lxml.de/FAQ.html#why-doesn-t-the-pretty-print-option-reformat-my-xml-output
+ # so serialize to XML, parse as XML (remove whitespace) then serialize
+ # as XML (pretty print)
+ arch_no_whitespace = etree.fromstring(
+ etree.tostring(arch, encoding='utf-8'),
+ parser=etree.XMLParser(encoding='utf-8', remove_blank_text=True))
+ return etree.tostring(
+ arch_no_whitespace, encoding='unicode', pretty_print=True)
+
+ def replace_arch_section(self, cr, uid, view_id, section_xpath, replacement, context=None):
+ # the root of the arch section shouldn't actually be replaced as it's
+ # not really editable itself, only the content truly is editable.
+
+ [view] = self.browse(cr, uid, [view_id], context=context)
+ arch = etree.fromstring(view.arch.encode('utf-8'))
+ # => get the replacement root
+ if not section_xpath:
+ root = arch
+ else:
+ # ensure there's only one match
+ [root] = arch.xpath(section_xpath)
+
+ root.text = replacement.text
+ root.tail = replacement.tail
+ # replace all children
+ del root[:]
+ for child in replacement:
+ root.append(copy.deepcopy(child))
+
+ return arch
+
+ def to_field_ref(self, cr, uid, el, context=None):
+ # filter out meta-information inserted in the document
+ attributes = dict((k, v) for k, v in el.items()
+ if not k.startswith('data-oe-'))
+ attributes['t-field'] = el.get('data-oe-expression')
+
+ out = html.html_parser.makeelement(el.tag, attrib=attributes)
+ out.tail = el.tail
+ return out
+
+ def save(self, cr, uid, res_id, value, xpath=None, context=None):
+ """ Update a view section. The view section may embed fields to write
+
+ :param str model:
+ :param int res_id:
+ :param str xpath: valid xpath to the tag to replace
+ """
+ res_id = int(res_id)
+
+ arch_section = html.fromstring(
+ value, parser=html.HTMLParser(encoding='utf-8'))
+
+ if xpath is None:
+ # value is an embedded field on its own, not a view section
+ self.save_embedded_field(cr, uid, arch_section, context=context)
+ return
+
+ for el in self.extract_embedded_fields(cr, uid, arch_section, context=context):
+ self.save_embedded_field(cr, uid, el, context=context)
+
+ # transform embedded field back to t-field
+ el.getparent().replace(el, self.to_field_ref(cr, uid, el, context=context))
+
+ arch = self.replace_arch_section(cr, uid, res_id, xpath, arch_section, context=context)
+ self.write(cr, uid, res_id, {
+ 'arch': self._pretty_arch(arch)
+ }, context=context)
+
+ view = self.browse(cr, openerp.SUPERUSER_ID, res_id, context=context)
+ if view.model_data_id:
+ view.model_data_id.write({'noupdate': True})
diff --git a/addons/web_editor/models/test_models.py b/addons/web_editor/models/test_models.py
new file mode 100644
index 00000000000..100b49b6448
--- /dev/null
+++ b/addons/web_editor/models/test_models.py
@@ -0,0 +1,43 @@
+# -*- coding: utf-8 -*-
+from openerp.osv import orm, fields
+
+
+class test_converter(orm.Model):
+ _name = 'web_editor.converter.test'
+
+ # disable translation export for those brilliant field labels and values
+ _translate = False
+
+ _columns = {
+ 'char': fields.char(),
+ 'integer': fields.integer(),
+ 'float': fields.float(),
+ 'numeric': fields.float(digits=(16, 2)),
+ 'many2one': fields.many2one('web_editor.converter.test.sub'),
+ 'binary': fields.binary(),
+ 'date': fields.date(),
+ 'datetime': fields.datetime(),
+ 'selection': fields.selection([
+ (1, "réponse A"),
+ (2, "réponse B"),
+ (3, "réponse C"),
+ (4, "réponse D"),
+ ]),
+ 'selection_str': fields.selection([
+ ('A', "Qu'il n'est pas arrivé à Toronto"),
+ ('B', "Qu'il était supposé arriver à Toronto"),
+ ('C', "Qu'est-ce qu'il fout ce maudit pancake, tabernacle ?"),
+ ('D', "La réponse D"),
+ ], string=u"Lorsqu'un pancake prend l'avion à destination de Toronto et "
+ u"qu'il fait une escale technique à St Claude, on dit:"),
+ 'html': fields.html(),
+ 'text': fields.text(),
+ }
+
+
+class test_converter_sub(orm.Model):
+ _name = 'web_editor.converter.test.sub'
+
+ _columns = {
+ 'name': fields.char(),
+ }