diff options
| author | Christophe Matthieu <chm@odoo.com> | 2015-07-07 16:02:53 +0200 |
|---|---|---|
| committer | Christophe Matthieu <chm@odoo.com> | 2015-07-10 17:00:11 +0200 |
| commit | 8c559044a5e1be111dcb577ad7e5ab462c1002a0 (patch) | |
| tree | ba1602e15b2bca38babd6cca63a41da1d05b4321 /addons/web_editor/models | |
| parent | a616c57c609da0e5e56835dcb45e1c05590d25b8 (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__.py | 7 | ||||
| -rw-r--r-- | addons/web_editor/models/ir_attachment.py | 149 | ||||
| -rw-r--r-- | addons/web_editor/models/ir_http.py | 16 | ||||
| -rw-r--r-- | addons/web_editor/models/ir_qweb.py | 566 | ||||
| -rw-r--r-- | addons/web_editor/models/ir_ui_view.py | 123 | ||||
| -rw-r--r-- | addons/web_editor/models/test_models.py | 43 |
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(), + } |
