# -*- coding: utf-8 -*- import base64 import re from collections import OrderedDict from io import BytesIO from odoo import api, fields, models, _ from PIL import Image import babel from odoo.tools import html_escape as escape, posix_to_ldml, safe_eval, float_utils, format_date, pycompat import logging _logger = logging.getLogger(__name__) def nl2br(string): """ Converts newlines to HTML linebreaks in ``string``. returns the unicode result :param str string: :rtype: unicode """ return pycompat.to_text(string).replace(u'\n', u'
\n') def html_escape(string, options): """ Automatically escapes content unless options['html-escape'] is set to False :param str string: :param dict options: """ return escape(string) if not options or options.get('html-escape', True) else string #-------------------------------------------------------------------- # QWeb Fields converters #-------------------------------------------------------------------- class FieldConverter(models.AbstractModel): """ Used to convert a t-field specification into an output HTML field. :meth:`~.to_html` is the entry point of this conversion from QWeb, it: * converts the record value to html using :meth:`~.record_to_html` * generates the metadata attributes (``data-oe-``) to set on the root result node * generates the root result node itself through :meth:`~.render_element` """ _name = 'ir.qweb.field' @api.model def attributes(self, record, field_name, options, values=None): """ attributes(record, field_name, field, options, values) Generates the metadata attributes (prefixed by ``data-oe-``) for the root node of the field conversion. The default attributes are: * ``model``, the name of the record's model * ``id`` the id of the record to which the field belongs * ``type`` the logical field type (widget, may not match the field's ``type``, may not be any Field subclass name) * ``translate``, a boolean flag (``0`` or ``1``) denoting whether the field is translatable * ``readonly``, has this attribute if the field is readonly * ``expression``, the original expression :returns: OrderedDict (attribute name, attribute value). """ data = OrderedDict() field = record._fields[field_name] if not options['inherit_branding'] and not options['translate']: return data data['data-oe-model'] = record._name data['data-oe-id'] = record.id data['data-oe-field'] = field.name data['data-oe-type'] = options.get('type') data['data-oe-expression'] = options.get('expression') if field.readonly: data['data-oe-readonly'] = 1 return data @api.model def value_to_html(self, value, options): """ value_to_html(value, field, options=None) Converts a single value to its HTML version/output :rtype: unicode """ return html_escape(pycompat.to_text(value), options) @api.model def record_to_html(self, record, field_name, options): """ record_to_html(record, field_name, options) Converts the specified field of the ``record`` to HTML :rtype: unicode """ if not record: return False value = record[field_name] return False if value is False else record.env[self._name].value_to_html(value, options=options) @api.model def user_lang(self): """ user_lang() Fetches the res.lang record corresponding to the language code stored in the user's context. Fallbacks to en_US if no lang is present in the context *or the language code is not valid*. :returns: Model[res.lang] """ lang_code = self._context.get('lang') or 'en_US' return self.env['res.lang']._lang_get(lang_code) class IntegerConverter(models.AbstractModel): _name = 'ir.qweb.field.integer' _inherit = 'ir.qweb.field' @api.model def value_to_html(self, value, options): return pycompat.to_text(self.user_lang().format('%d', value, grouping=True).replace(r'-', u'\u2011')) class FloatConverter(models.AbstractModel): _name = 'ir.qweb.field.float' _inherit = 'ir.qweb.field' @api.model def value_to_html(self, value, options): if 'decimal_precision' in options: precision = self.env['decimal.precision'].search([('name', '=', options['decimal_precision'])]).digits else: precision = options['precision'] if precision is None: fmt = '%f' else: value = float_utils.float_round(value, precision_digits=precision) fmt = '%.{precision}f'.format(precision=precision) formatted = self.user_lang().format(fmt, value, grouping=True).replace(r'-', u'\u2011') # %f does not strip trailing zeroes. %g does but its precision causes # it to switch to scientific notation starting at a million *and* to # strip decimals. So use %f and if no precision was specified manually # strip trailing 0. if precision is None: formatted = re.sub(r'(?:(0|\d+?)0+)$', r'\1', formatted) return pycompat.to_text(formatted) @api.model def record_to_html(self, record, field_name, options): if 'precision' not in options and 'decimal_precision' not in options: _, precision = record._fields[field_name].digits or (None, None) options = dict(options, precision=precision) return super(FloatConverter, self).record_to_html(record, field_name, options) class DateConverter(models.AbstractModel): _name = 'ir.qweb.field.date' _inherit = 'ir.qweb.field' @api.model def value_to_html(self, value, options): return format_date(self.env, value, date_format=(options or {}).get('format')) class DateTimeConverter(models.AbstractModel): _name = 'ir.qweb.field.datetime' _inherit = 'ir.qweb.field' @api.model def value_to_html(self, value, options): if not value: return '' lang = self.user_lang() locale = babel.Locale.parse(lang.code) if isinstance(value, pycompat.string_types): value = fields.Datetime.from_string(value) value = fields.Datetime.context_timestamp(self, value) if options and 'format' in options: pattern = options['format'] else: if options and options.get('time_only'): strftime_pattern = (u"%s" % (lang.time_format)) else: strftime_pattern = (u"%s %s" % (lang.date_format, lang.time_format)) pattern = posix_to_ldml(strftime_pattern, locale=locale) if options and options.get('hide_seconds'): pattern = pattern.replace(":ss", "").replace(":s", "") return pycompat.to_text(babel.dates.format_datetime(value, format=pattern, locale=locale)) class TextConverter(models.AbstractModel): _name = 'ir.qweb.field.text' _inherit = 'ir.qweb.field' @api.model def value_to_html(self, value, options): """ Escapes the value and converts newlines to br. This is bullshit. """ return nl2br(html_escape(value, options)) if value else '' class SelectionConverter(models.AbstractModel): _name = 'ir.qweb.field.selection' _inherit = 'ir.qweb.field' @api.model def value_to_html(self, value, options): if not value: return '' return html_escape(pycompat.to_text(options['selection'][value]) or u'', options) @api.model def record_to_html(self, record, field_name, options): if 'selection' not in options: options = dict(options, selection=dict(record._fields[field_name].get_description(self.env)['selection'])) return super(SelectionConverter, self).record_to_html(record, field_name, options) class ManyToOneConverter(models.AbstractModel): _name = 'ir.qweb.field.many2one' _inherit = 'ir.qweb.field' @api.model def value_to_html(self, value, options): if not value: return False value = value.sudo().display_name if not value: return False return nl2br(html_escape(value, options)) if value else '' class HTMLConverter(models.AbstractModel): _name = 'ir.qweb.field.html' _inherit = 'ir.qweb.field' @api.model def value_to_html(self, value, options): return pycompat.to_text(value) class ImageConverter(models.AbstractModel): """ ``image`` widget rendering, inserts a data:uri-using image tag in the document. May be overridden by e.g. the website module to generate links instead. .. todo:: what happens if different output need different converters? e.g. reports may need embedded images or FS links whereas website needs website-aware """ _name = 'ir.qweb.field.image' _inherit = 'ir.qweb.field' @api.model def value_to_html(self, value, options): try: # FIXME: maaaaaybe it could also take raw bytes? image = Image.open(BytesIO(base64.b64decode(value))) image.verify() except IOError: raise ValueError("Non-image binary fields can not be converted to HTML") except: # image.verify() throws "suitable exceptions", I have no idea what they are raise ValueError("Invalid image content") return u'' % (Image.MIME[image.format], value.decode('ascii')) class MonetaryConverter(models.AbstractModel): """ ``monetary`` converter, has a mandatory option ``display_currency`` only if field is not of type Monetary. Otherwise, if we are in presence of a monetary field, the field definition must have a currency_field attribute set. The currency is used for formatting *and rounding* of the float value. It is assumed that the linked res_currency has a non-empty rounding value and res.currency's ``round`` method is used to perform rounding. .. note:: the monetary converter internally adds the qweb context to its options mapping, so that the context is available to callees. It's set under the ``_values`` key. """ _name = 'ir.qweb.field.monetary' _inherit = 'ir.qweb.field' @api.model def value_to_html(self, value, options): display_currency = options['display_currency'] # lang.format mandates a sprintf-style format. These formats are non- # minimal (they have a default fixed precision instead), and # lang.format will not set one by default. currency.round will not # provide one either. So we need to generate a precision value # (integer > 0) from the currency's rounding (a float generally < 1.0). fmt = "%.{0}f".format(display_currency.decimal_places) if options.get('from_currency'): value = options['from_currency'].compute(value, display_currency) lang = self.user_lang() formatted_amount = lang.format(fmt, display_currency.round(value), grouping=True, monetary=True).replace(r' ', u'\N{NO-BREAK SPACE}').replace(r'-', u'\u2011') pre = post = u'' if display_currency.position == 'before': pre = u'{symbol}\N{NO-BREAK SPACE}'.format(symbol=display_currency.symbol or '') else: post = u'\N{NO-BREAK SPACE}{symbol}'.format(symbol=display_currency.symbol or '') return u'{pre}{0}{post}'.format(formatted_amount, pre=pre, post=post) @api.model def record_to_html(self, record, field_name, options): options = dict(options) #currency should be specified by monetary field field = record._fields[field_name] if not options.get('display_currency') and field.type == 'monetary' and field.currency_field: options['display_currency'] = record[field.currency_field] return super(MonetaryConverter, self).record_to_html(record, field_name, options) TIMEDELTA_UNITS = ( ('year', 3600 * 24 * 365), ('month', 3600 * 24 * 30), ('week', 3600 * 24 * 7), ('day', 3600 * 24), ('hour', 3600), ('minute', 60), ('second', 1) ) class FloatTimeConverter(models.AbstractModel): """ ``float_time`` converter, to display integral or fractional values as human-readable time spans (e.g. 1.5 as "01:30"). Can be used on any numerical field. """ _name = 'ir.qweb.field.float_time' _inherit = 'ir.qweb.field' @api.model def value_to_html(self, value, options): hours, minutes = divmod(value * 60, 60) return '%02d:%02d' % (hours, minutes) class DurationConverter(models.AbstractModel): """ ``duration`` converter, to display integral or fractional values as human-readable time spans (e.g. 1.5 as "1 hour 30 minutes"). Can be used on any numerical field. Has a mandatory option ``unit`` which can be one of ``second``, ``minute``, ``hour``, ``day``, ``week`` or ``year``, used to interpret the numerical field value before converting it. Sub-second values will be ignored. """ _name = 'ir.qweb.field.duration' _inherit = 'ir.qweb.field' @api.model def value_to_html(self, value, options): units = dict(TIMEDELTA_UNITS) if value < 0: raise ValueError(_("Durations can't be negative")) if not options or options.get('unit') not in units: raise ValueError(_("A unit must be provided to duration widgets")) locale = babel.Locale.parse(self.user_lang().code) factor = units[options['unit']] sections = [] r = value * factor if options.get('round') in units: round_to = units[options['round']] r = round(r / round_to) * round_to for unit, secs_per_unit in TIMEDELTA_UNITS: v, r = divmod(r, secs_per_unit) if not v: continue section = babel.dates.format_timedelta( v*secs_per_unit, threshold=1, locale=locale) if section: sections.append(section) return u' '.join(sections) class RelativeDatetimeConverter(models.AbstractModel): _name = 'ir.qweb.field.relative' _inherit = 'ir.qweb.field' @api.model def value_to_html(self, value, options): locale = babel.Locale.parse(self.user_lang().code) if isinstance(value, pycompat.string_types): value = fields.Datetime.from_string(value) # value should be a naive datetime in UTC. So is fields.Datetime.now() reference = fields.Datetime.from_string(options['now']) return pycompat.to_text(babel.dates.format_timedelta(value - reference, add_direction=True, locale=locale)) @api.model def record_to_html(self, record, field_name, options): if 'now' not in options: options = dict(options, now=record._fields[field_name].now()) return super(RelativeDatetimeConverter, self).record_to_html(record, field_name, options) class BarcodeConverter(models.AbstractModel): """ ``barcode`` widget rendering, inserts a data:uri-using image tag in the document. May be overridden by e.g. the website module to generate links instead. """ _name = 'ir.qweb.field.barcode' _inherit = 'ir.qweb.field' @api.model def value_to_html(self, value, options=None): barcode_type = options.get('type', 'Code128') barcode = self.env['ir.actions.report'].barcode( barcode_type, value, **{key: value for key, value in options.items() if key in ['width', 'height', 'humanreadable']}) return u'' % base64.b64encode(barcode).decode('ascii') @api.model def from_html(self, model, field, element): return None class Contact(models.AbstractModel): _name = 'ir.qweb.field.contact' _inherit = 'ir.qweb.field.many2one' @api.model def value_to_html(self, value, options): if not value.exists(): return False opf = options and options.get('fields') or ["name", "address", "phone", "mobile", "email"] value = value.sudo().with_context(show_address=True) name_get = value.name_get()[0][1] val = { 'name': name_get.split("\n")[0], 'address': escape("\n".join(name_get.split("\n")[1:])).strip(), 'phone': value.phone, 'mobile': value.mobile, 'city': value.city, 'country_id': value.country_id.display_name, 'website': value.website, 'email': value.email, 'fields': opf, 'object': value, 'options': options } return self.env['ir.qweb'].render('base.contact', val) class QwebView(models.AbstractModel): _name = 'ir.qweb.field.qweb' _inherit = 'ir.qweb.field.many2one' @api.model def record_to_html(self, record, field_name, options): if not getattr(record, field_name): return None view = getattr(record, field_name) if view._name != "ir.ui.view": _logger.warning("%s.%s must be a 'ir.ui.view' model." % (record, field_name)) return None view = view.with_context(object=record) return pycompat.to_text(view.render(view._context, engine='ir.qweb'))