See discussions on https://github.com/odoo/odoo/pull/85494/. TLDR: webp image format needs to be supported, but we should avoid going through the Pillow library as it is largely unsafe for that format. jpg attachment are created in JS at upload time. Wkhtmltopdf doesn't support webp, so, in reports, we should display one of those jpg copies. This work is handled by `ir.qweb: _get_converted_image_data_uri` which is used as: ```xml <img src="image_data_uri(some_b64value)" /> ``` The mentioned PR did not however adapt the ir.qweb.field.image that, when passed the option `qweb_img_raw_data` should return a base64 url such as `data:[mimetype],base64,[datas]`. usage: ```xml <span t-field="object.image_field" t-options-widget="'image'" t-options-qweb_img_raw_data="1" /> ``` Hence, before this commit, there was a crash as we tried to pass that value to PIL. After this commit, there is no crash, and the image displays correctly as JPG in the PDF opw-3859423 closes odoo/odoo#163003 X-original-commit: 9056a4b1f28e820c0444f28367cc30abebd5ea30 Signed-off-by: Benoit Socias (bso) <bso@odoo.com> Signed-off-by: Lucas Perais (lpe) <lpe@odoo.com>
849 lines
33 KiB
Python
849 lines
33 KiB
Python
# -*- coding: utf-8 -*-
|
|
import base64
|
|
import binascii
|
|
from datetime import time
|
|
import logging
|
|
import re
|
|
from io import BytesIO
|
|
|
|
import babel
|
|
import babel.dates
|
|
from markupsafe import Markup, escape
|
|
from PIL import Image
|
|
from lxml import etree, html
|
|
|
|
from odoo import api, fields, models, _, _lt, tools
|
|
from odoo.tools import posix_to_ldml, float_utils, format_date, format_duration, pycompat
|
|
from odoo.tools.mail import safe_attrs
|
|
from odoo.tools.misc import get_lang, babel_locale_parse
|
|
from odoo.tools.mimetypes import guess_mimetype
|
|
|
|
_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('\n', Markup('<br>\n'))
|
|
|
|
|
|
def nl2br_enclose(string, enclosure_tag='div'):
|
|
""" Like nl2br, but returns enclosed Markup allowing to better manipulate
|
|
trusted and untrusted content. New lines added by use are trusted, other
|
|
content is escaped. """
|
|
converted = nl2br(escape(string))
|
|
return Markup(f'<{enclosure_tag}>{converted}</{enclosure_tag}>')
|
|
|
|
#--------------------------------------------------------------------
|
|
# 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'
|
|
_description = 'Qweb Field'
|
|
|
|
@api.model
|
|
def get_available_options(self):
|
|
"""
|
|
Get the available option informations.
|
|
|
|
Returns a dict of dict with:
|
|
* key equal to the option key.
|
|
* dict: type, params, name, description, default_value
|
|
* type:
|
|
'string'
|
|
'integer'
|
|
'float'
|
|
'model' (e.g. 'res.partner')
|
|
'array'
|
|
'selection' (e.g. [key1, key2...])
|
|
"""
|
|
return {}
|
|
|
|
@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: dict (attribute name, attribute value).
|
|
"""
|
|
data = {}
|
|
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 escape(pycompat.to_text(value))
|
|
|
|
@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.with_context(**self.env.context)[field_name]
|
|
return False if value is False else self.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.
|
|
|
|
:returns: Model[res.lang]
|
|
"""
|
|
return get_lang(self.env)
|
|
|
|
|
|
class IntegerConverter(models.AbstractModel):
|
|
_name = 'ir.qweb.field.integer'
|
|
_description = 'Qweb Field Integer'
|
|
_inherit = 'ir.qweb.field'
|
|
|
|
@api.model
|
|
def get_available_options(self):
|
|
options = super(IntegerConverter, self).get_available_options()
|
|
options.update(
|
|
format_decimalized_number=dict(type='boolean', string=_('Decimalized number')),
|
|
precision_digits=dict(type='integer', string=_('Precision Digits')),
|
|
)
|
|
return options
|
|
|
|
@api.model
|
|
def value_to_html(self, value, options):
|
|
if options.get('format_decimalized_number'):
|
|
return tools.format_decimalized_number(value, options.get('precision_digits', 1))
|
|
return pycompat.to_text(self.user_lang().format('%d', value, grouping=True).replace(r'-', '-\N{ZERO WIDTH NO-BREAK SPACE}'))
|
|
|
|
|
|
class FloatConverter(models.AbstractModel):
|
|
_name = 'ir.qweb.field.float'
|
|
_description = 'Qweb Field Float'
|
|
_inherit = 'ir.qweb.field'
|
|
|
|
@api.model
|
|
def get_available_options(self):
|
|
options = super(FloatConverter, self).get_available_options()
|
|
options.update(
|
|
precision=dict(type='integer', string=_('Rounding precision')),
|
|
)
|
|
return options
|
|
|
|
@api.model
|
|
def value_to_html(self, value, options):
|
|
if 'decimal_precision' in options:
|
|
precision = self.env['decimal.precision'].precision_get(options['decimal_precision'])
|
|
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'-', '-\N{ZERO WIDTH NO-BREAK SPACE}')
|
|
|
|
# %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].get_digits(record.env) 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'
|
|
_description = 'Qweb Field Date'
|
|
_inherit = 'ir.qweb.field'
|
|
|
|
@api.model
|
|
def get_available_options(self):
|
|
options = super(DateConverter, self).get_available_options()
|
|
options.update(
|
|
format=dict(type='string', string=_('Date format'))
|
|
)
|
|
return options
|
|
|
|
@api.model
|
|
def value_to_html(self, value, options):
|
|
return format_date(self.env, value, date_format=options.get('format'))
|
|
|
|
|
|
class DateTimeConverter(models.AbstractModel):
|
|
_name = 'ir.qweb.field.datetime'
|
|
_description = 'Qweb Field Datetime'
|
|
_inherit = 'ir.qweb.field'
|
|
|
|
@api.model
|
|
def get_available_options(self):
|
|
options = super(DateTimeConverter, self).get_available_options()
|
|
options.update(
|
|
format=dict(type='string', string=_('Pattern to format')),
|
|
tz_name=dict(type='char', string=_('Optional timezone name')),
|
|
time_only=dict(type='boolean', string=_('Display only the time')),
|
|
hide_seconds=dict(type='boolean', string=_('Hide seconds')),
|
|
date_only=dict(type='boolean', string=_('Display only the date')),
|
|
)
|
|
return options
|
|
|
|
@api.model
|
|
def value_to_html(self, value, options):
|
|
if not value:
|
|
return ''
|
|
|
|
lang = self.user_lang()
|
|
locale = babel_locale_parse(lang.code)
|
|
format_func = babel.dates.format_datetime
|
|
if isinstance(value, str):
|
|
value = fields.Datetime.from_string(value)
|
|
|
|
if options.get('tz_name'):
|
|
self = self.with_context(tz=options['tz_name'])
|
|
tzinfo = babel.dates.get_timezone(options['tz_name'])
|
|
else:
|
|
tzinfo = None
|
|
|
|
value = fields.Datetime.context_timestamp(self, value)
|
|
|
|
if 'format' in options:
|
|
pattern = options['format']
|
|
else:
|
|
if options.get('time_only'):
|
|
strftime_pattern = ("%s" % (lang.time_format))
|
|
elif options.get('date_only'):
|
|
strftime_pattern = ("%s" % (lang.date_format))
|
|
else:
|
|
strftime_pattern = ("%s %s" % (lang.date_format, lang.time_format))
|
|
|
|
pattern = posix_to_ldml(strftime_pattern, locale=locale)
|
|
|
|
if options.get('hide_seconds'):
|
|
pattern = pattern.replace(":ss", "").replace(":s", "")
|
|
|
|
if options.get('time_only'):
|
|
format_func = babel.dates.format_time
|
|
return pycompat.to_text(format_func(value, format=pattern, tzinfo=tzinfo, locale=locale))
|
|
if options.get('date_only'):
|
|
format_func = babel.dates.format_date
|
|
return pycompat.to_text(format_func(value, format=pattern, locale=locale))
|
|
|
|
return pycompat.to_text(format_func(value, format=pattern, tzinfo=tzinfo, locale=locale))
|
|
|
|
|
|
class TextConverter(models.AbstractModel):
|
|
_name = 'ir.qweb.field.text'
|
|
_description = '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(escape(value)) if value else ''
|
|
|
|
|
|
class SelectionConverter(models.AbstractModel):
|
|
_name = 'ir.qweb.field.selection'
|
|
_description = 'Qweb Field Selection'
|
|
_inherit = 'ir.qweb.field'
|
|
|
|
@api.model
|
|
def get_available_options(self):
|
|
options = super(SelectionConverter, self).get_available_options()
|
|
options.update(
|
|
selection=dict(type='selection', string=_('Selection'), description=_('By default the widget uses the field information'), required=True)
|
|
)
|
|
options.update(
|
|
selection=dict(type='json', string=_('Json'), description=_('By default the widget uses the field information'), required=True)
|
|
)
|
|
return options
|
|
|
|
@api.model
|
|
def value_to_html(self, value, options):
|
|
if not value:
|
|
return ''
|
|
return escape(pycompat.to_text(options['selection'][value]) or '')
|
|
|
|
@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'
|
|
_description = 'Qweb Field Many to One'
|
|
_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(escape(value))
|
|
|
|
|
|
class ManyToManyConverter(models.AbstractModel):
|
|
_name = 'ir.qweb.field.many2many'
|
|
_description = 'Qweb field many2many'
|
|
_inherit = 'ir.qweb.field'
|
|
|
|
@api.model
|
|
def value_to_html(self, value, options):
|
|
if not value:
|
|
return False
|
|
text = ', '.join(value.sudo().mapped('display_name'))
|
|
return nl2br(escape(text))
|
|
|
|
|
|
class HTMLConverter(models.AbstractModel):
|
|
_name = 'ir.qweb.field.html'
|
|
_description = 'Qweb Field HTML'
|
|
_inherit = 'ir.qweb.field'
|
|
|
|
@api.model
|
|
def value_to_html(self, value, options):
|
|
irQweb = self.env['ir.qweb']
|
|
# wrap value inside a body and parse it as HTML
|
|
body = etree.fromstring("<body>%s</body>" % value, etree.HTMLParser(encoding='utf-8'))[0]
|
|
# use pos processing for all nodes with attributes
|
|
for element in body.iter():
|
|
if element.attrib:
|
|
attrib = dict(element.attrib)
|
|
attrib = irQweb._post_processing_att(element.tag, attrib)
|
|
element.attrib.clear()
|
|
element.attrib.update(attrib)
|
|
return Markup(etree.tostring(body, encoding='unicode', method='html')[6:-7])
|
|
|
|
|
|
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'
|
|
_description = 'Qweb Field Image'
|
|
_inherit = 'ir.qweb.field'
|
|
|
|
@api.model
|
|
def _get_src_data_b64(self, value, options):
|
|
try:
|
|
img_b64 = base64.b64decode(value)
|
|
except binascii.Error:
|
|
raise ValueError("Invalid image content") from None
|
|
|
|
if img_b64 and guess_mimetype(img_b64, '') == 'image/webp':
|
|
return self.env["ir.qweb"]._get_converted_image_data_uri(value)
|
|
|
|
try:
|
|
image = Image.open(BytesIO(img_b64))
|
|
image.verify()
|
|
except IOError:
|
|
raise ValueError("Non-image binary fields can not be converted to HTML") from None
|
|
except: # image.verify() throws "suitable exceptions", I have no idea what they are
|
|
raise ValueError("Invalid image content") from None
|
|
|
|
return "data:%s;base64,%s" % (Image.MIME[image.format], value.decode('ascii'))
|
|
|
|
@api.model
|
|
def value_to_html(self, value, options):
|
|
return Markup('<img src="%s">') % self._get_src_data_b64(value, options)
|
|
|
|
class ImageUrlConverter(models.AbstractModel):
|
|
""" ``image_url`` widget rendering, inserts an image tag in the
|
|
document.
|
|
"""
|
|
_name = 'ir.qweb.field.image_url'
|
|
_description = 'Qweb Field Image'
|
|
_inherit = 'ir.qweb.field.image'
|
|
|
|
@api.model
|
|
def value_to_html(self, value, options):
|
|
return Markup('<img src="%s">' % (value))
|
|
|
|
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'
|
|
_description = 'Qweb Field Monetary'
|
|
_inherit = 'ir.qweb.field'
|
|
|
|
@api.model
|
|
def get_available_options(self):
|
|
options = super(MonetaryConverter, self).get_available_options()
|
|
options.update(
|
|
from_currency=dict(type='model', params='res.currency', string=_('Original currency')),
|
|
display_currency=dict(type='model', params='res.currency', string=_('Display currency'), required="value_to_html"),
|
|
date=dict(type='date', string=_('Date'), description=_('Date used for the original currency (only used for t-esc). by default use the current date.')),
|
|
company_id=dict(type='model', params='res.company', string=_('Company'), description=_('Company used for the original currency (only used for t-esc). By default use the user company')),
|
|
)
|
|
return options
|
|
|
|
@api.model
|
|
def value_to_html(self, value, options):
|
|
display_currency = options['display_currency']
|
|
|
|
if not isinstance(value, (int, float)):
|
|
raise ValueError(_("The value send to monetary field is not a number."))
|
|
|
|
# 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(options.get('decimal_places', display_currency.decimal_places))
|
|
|
|
if options.get('from_currency'):
|
|
date = options.get('date') or fields.Date.today()
|
|
company_id = options.get('company_id')
|
|
if company_id:
|
|
company = self.env['res.company'].browse(company_id)
|
|
else:
|
|
company = self.env.company
|
|
value = options['from_currency']._convert(value, display_currency, company, date)
|
|
|
|
lang = self.user_lang()
|
|
formatted_amount = lang.format(fmt, display_currency.round(value),
|
|
grouping=True, monetary=True).replace(r' ', '\N{NO-BREAK SPACE}').replace(r'-', '-\N{ZERO WIDTH NO-BREAK SPACE}')
|
|
|
|
pre = post = ''
|
|
if display_currency.position == 'before':
|
|
pre = '{symbol}\N{NO-BREAK SPACE}'.format(symbol=display_currency.symbol or '')
|
|
else:
|
|
post = '\N{NO-BREAK SPACE}{symbol}'.format(symbol=display_currency.symbol or '')
|
|
|
|
if options.get('label_price') and lang.decimal_point in formatted_amount:
|
|
sep = lang.decimal_point
|
|
integer_part, decimal_part = formatted_amount.split(sep)
|
|
integer_part += sep
|
|
return Markup('{pre}<span class="oe_currency_value">{0}</span><span class="oe_currency_value" style="font-size:0.5em">{1}</span>{post}').format(integer_part, decimal_part, pre=pre, post=post)
|
|
|
|
return Markup('{pre}<span class="oe_currency_value">{0}</span>{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.get_currency_field(record):
|
|
options['display_currency'] = record[field.get_currency_field(record)]
|
|
if not options.get('display_currency'):
|
|
# search on the model if they are a res.currency field to set as default
|
|
fields = record._fields.items()
|
|
currency_fields = [k for k, v in fields if v.type == 'many2one' and v.comodel_name == 'res.currency']
|
|
if currency_fields:
|
|
options['display_currency'] = record[currency_fields[0]]
|
|
if 'date' not in options:
|
|
options['date'] = record._context.get('date')
|
|
if 'company_id' not in options:
|
|
options['company_id'] = record._context.get('company_id')
|
|
|
|
return super(MonetaryConverter, self).record_to_html(record, field_name, options)
|
|
|
|
|
|
TIMEDELTA_UNITS = (
|
|
('year', _lt('year'), 3600 * 24 * 365),
|
|
('month', _lt('month'), 3600 * 24 * 30),
|
|
('week', _lt('week'), 3600 * 24 * 7),
|
|
('day', _lt('day'), 3600 * 24),
|
|
('hour', _lt('hour'), 3600),
|
|
('minute', _lt('minute'), 60),
|
|
('second', _lt('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'
|
|
_description = 'Qweb Field Float Time'
|
|
_inherit = 'ir.qweb.field'
|
|
|
|
@api.model
|
|
def value_to_html(self, value, options):
|
|
return format_duration(value)
|
|
|
|
|
|
class TimeConverter(models.AbstractModel):
|
|
""" ``time`` converter, to display integer or fractional value as
|
|
human-readable time (e.g. 1.5 as "1:30 AM"). The unit of this value
|
|
is in hours.
|
|
|
|
Can be used on any numerical field between: 0 <= value < 24
|
|
"""
|
|
_name = 'ir.qweb.field.time'
|
|
_description = 'QWeb Field Time'
|
|
_inherit = 'ir.qweb.field'
|
|
|
|
@api.model
|
|
def value_to_html(self, value, options):
|
|
if value < 0:
|
|
raise ValueError(_("The value (%s) passed should be positive", value))
|
|
hours, minutes = divmod(int(abs(value) * 60), 60)
|
|
if hours > 23:
|
|
raise ValueError(_("The hour must be between 0 and 23"))
|
|
t = time(hour=hours, minute=minutes)
|
|
|
|
locale = babel_locale_parse(self.user_lang().code)
|
|
pattern = options.get('format', 'short')
|
|
|
|
return babel.dates.format_time(t, format=pattern, tzinfo=None, locale=locale)
|
|
|
|
|
|
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 an option ``unit`` which can be one of ``second``, ``minute``,
|
|
``hour``, ``day``, ``week`` or ``year``, used to interpret the numerical
|
|
field value before converting it. By default use ``second``.
|
|
|
|
Has an option ``round``. By default use ``second``.
|
|
|
|
Has an option ``digital`` to display 01:00 instead of 1 hour
|
|
|
|
Sub-second values will be ignored.
|
|
"""
|
|
_name = 'ir.qweb.field.duration'
|
|
_description = 'Qweb Field Duration'
|
|
_inherit = 'ir.qweb.field'
|
|
|
|
@api.model
|
|
def get_available_options(self):
|
|
options = super(DurationConverter, self).get_available_options()
|
|
unit = [(value, str(label)) for value, label, ratio in TIMEDELTA_UNITS]
|
|
options.update(
|
|
digital=dict(type="boolean", string=_('Digital formatting')),
|
|
unit=dict(type="selection", params=unit, string=_('Date unit'), description=_('Date unit used for comparison and formatting'), default_value='second', required=True),
|
|
round=dict(type="selection", params=unit, string=_('Rounding unit'), description=_("Date unit used for the rounding. The value must be smaller than 'hour' if you use the digital formatting."), default_value='second'),
|
|
format=dict(
|
|
type="selection",
|
|
params=[
|
|
('long', _('Long')),
|
|
('short', _('Short')),
|
|
('narrow', _('Narrow'))],
|
|
string=_('Format'),
|
|
description=_("Formatting: long, short, narrow (not used for digital)"),
|
|
default_value='long'
|
|
),
|
|
add_direction=dict(
|
|
type="boolean",
|
|
string=_("Add direction"),
|
|
description=_("Add directional information (not used for digital)")
|
|
),
|
|
)
|
|
return options
|
|
|
|
@api.model
|
|
def value_to_html(self, value, options):
|
|
units = {unit: duration for unit, label, duration in TIMEDELTA_UNITS}
|
|
|
|
locale = babel_locale_parse(self.user_lang().code)
|
|
factor = units[options.get('unit', 'second')]
|
|
round_to = units[options.get('round', 'second')]
|
|
|
|
if options.get('digital') and round_to > 3600:
|
|
round_to = 3600
|
|
|
|
r = round((value * factor) / round_to) * round_to
|
|
|
|
sections = []
|
|
sign = ''
|
|
if value < 0:
|
|
r = -r
|
|
sign = '-'
|
|
|
|
if options.get('digital'):
|
|
for unit, label, secs_per_unit in TIMEDELTA_UNITS:
|
|
if secs_per_unit > 3600:
|
|
continue
|
|
v, r = divmod(r, secs_per_unit)
|
|
if not v and (secs_per_unit > factor or secs_per_unit < round_to):
|
|
continue
|
|
sections.append(u"%02.0f" % int(round(v)))
|
|
return sign + u':'.join(sections)
|
|
|
|
for unit, label, secs_per_unit in TIMEDELTA_UNITS:
|
|
v, r = divmod(r, secs_per_unit)
|
|
if not v:
|
|
continue
|
|
try:
|
|
section = babel.dates.format_timedelta(
|
|
v*secs_per_unit,
|
|
granularity=round_to,
|
|
add_direction=options.get('add_direction'),
|
|
format=options.get('format', 'long'),
|
|
threshold=1,
|
|
locale=locale)
|
|
except KeyError:
|
|
# in case of wrong implementation of babel, try to fallback on en_US locale.
|
|
# https://github.com/python-babel/babel/pull/827/files
|
|
# Some bugs already fixed in 2.10 but ubuntu22 is 2.8
|
|
localeUS = babel_locale_parse('en_US')
|
|
section = babel.dates.format_timedelta(
|
|
v*secs_per_unit,
|
|
granularity=round_to,
|
|
add_direction=options.get('add_direction'),
|
|
format=options.get('format', 'long'),
|
|
threshold=1,
|
|
locale=localeUS)
|
|
if section:
|
|
sections.append(section)
|
|
|
|
if sign:
|
|
sections.insert(0, sign)
|
|
return u' '.join(sections)
|
|
|
|
|
|
class RelativeDatetimeConverter(models.AbstractModel):
|
|
_name = 'ir.qweb.field.relative'
|
|
_description = 'Qweb Field Relative'
|
|
_inherit = 'ir.qweb.field'
|
|
|
|
@api.model
|
|
def get_available_options(self):
|
|
options = super(RelativeDatetimeConverter, self).get_available_options()
|
|
options.update(
|
|
now=dict(type='datetime', string=_('Reference date'), description=_('Date to compare with the field value, by default use the current date.'))
|
|
)
|
|
return options
|
|
|
|
@api.model
|
|
def value_to_html(self, value, options):
|
|
locale = babel_locale_parse(self.user_lang().code)
|
|
|
|
if isinstance(value, str):
|
|
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'
|
|
_description = 'Qweb Field Barcode'
|
|
_inherit = 'ir.qweb.field'
|
|
|
|
@api.model
|
|
def get_available_options(self):
|
|
options = super(BarcodeConverter, self).get_available_options()
|
|
options.update(
|
|
symbology=dict(type='string', string=_('Barcode symbology'), description=_('Barcode type, eg: UPCA, EAN13, Code128'), default_value='Code128'),
|
|
width=dict(type='integer', string=_('Width'), default_value=600),
|
|
height=dict(type='integer', string=_('Height'), default_value=100),
|
|
humanreadable=dict(type='integer', string=_('Human Readable'), default_value=0),
|
|
quiet=dict(type='integer', string='Quiet', default_value=1),
|
|
mask=dict(type='string', string='Mask', default_value='')
|
|
)
|
|
return options
|
|
|
|
@api.model
|
|
def value_to_html(self, value, options=None):
|
|
if not value:
|
|
return ''
|
|
barcode_symbology = options.get('symbology', 'Code128')
|
|
barcode = self.env['ir.actions.report'].barcode(
|
|
barcode_symbology,
|
|
value,
|
|
**{key: value for key, value in options.items() if key in ['width', 'height', 'humanreadable', 'quiet', 'mask']})
|
|
|
|
img_element = html.Element('img')
|
|
for k, v in options.items():
|
|
if k.startswith('img_') and k[4:] in safe_attrs:
|
|
img_element.set(k[4:], v)
|
|
if not img_element.get('alt'):
|
|
img_element.set('alt', _('Barcode %s', value))
|
|
img_element.set('src', 'data:image/png;base64,%s' % base64.b64encode(barcode).decode())
|
|
return Markup(html.tostring(img_element, encoding='unicode'))
|
|
|
|
|
|
class Contact(models.AbstractModel):
|
|
_name = 'ir.qweb.field.contact'
|
|
_description = 'Qweb Field Contact'
|
|
_inherit = 'ir.qweb.field.many2one'
|
|
|
|
@api.model
|
|
def get_available_options(self):
|
|
options = super(Contact, self).get_available_options()
|
|
contact_fields = [
|
|
{'field_name': 'name', 'label': _('Name'), 'default': True},
|
|
{'field_name': 'address', 'label': _('Address'), 'default': True},
|
|
{'field_name': 'phone', 'label': _('Phone'), 'default': True},
|
|
{'field_name': 'mobile', 'label': _('Mobile'), 'default': True},
|
|
{'field_name': 'email', 'label': _('Email'), 'default': True},
|
|
{'field_name': 'vat', 'label': _('VAT')},
|
|
]
|
|
separator_params = dict(
|
|
type='selection',
|
|
selection=[[" ", _("Space")], [",", _("Comma")], ["-", _("Dash")], ["|", _("Vertical bar")], ["/", _("Slash")]],
|
|
placeholder=_('Linebreak'),
|
|
)
|
|
options.update(
|
|
fields=dict(type='array', params=dict(type='selection', params=contact_fields), string=_('Displayed fields'), description=_('List of contact fields to display in the widget'), default_value=[param.get('field_name') for param in contact_fields if param.get('default')]),
|
|
separator=dict(type='selection', params=separator_params, string=_('Address separator'), description=_('Separator use to split the address from the display_name.'), default_value=False),
|
|
no_marker=dict(type='boolean', string=_('Hide badges'), description=_("Don't display the font awesome marker")),
|
|
no_tag_br=dict(type='boolean', string=_('Use comma'), description=_("Use comma instead of the <br> tag to display the address")),
|
|
phone_icons=dict(type='boolean', string=_('Display phone icons'), description=_("Display the phone icons even if no_marker is True")),
|
|
country_image=dict(type='boolean', string=_('Display country image'), description=_("Display the country image if the field is present on the record")),
|
|
)
|
|
return options
|
|
|
|
@api.model
|
|
def value_to_html(self, value, options):
|
|
if not value:
|
|
if options.get('null_text'):
|
|
val = {
|
|
'options': options,
|
|
}
|
|
template_options = options.get('template_options', {})
|
|
return self.env['ir.qweb']._render('base.no_contact', val, **template_options)
|
|
return ''
|
|
|
|
opf = options.get('fields') or ["name", "address", "phone", "mobile", "email"]
|
|
sep = options.get('separator')
|
|
if sep:
|
|
opsep = escape(sep)
|
|
elif options.get('no_tag_br'):
|
|
# escaped joiners will auto-escape joined params
|
|
opsep = escape(', ')
|
|
else:
|
|
opsep = Markup('<br/>')
|
|
|
|
value = value.sudo().with_context(show_address=True)
|
|
display_name = value.display_name or ''
|
|
# Avoid having something like:
|
|
# display_name = 'Foo\n \n' -> This is a res.partner with a name and no address
|
|
# That would return markup('<br/>') as address. But there is no address set.
|
|
if any(elem.strip() for elem in display_name.split("\n")[1:]):
|
|
address = opsep.join(display_name.split("\n")[1:]).strip()
|
|
else:
|
|
address = ''
|
|
val = {
|
|
'name': display_name.split("\n")[0],
|
|
'address': address,
|
|
'phone': value.phone,
|
|
'mobile': value.mobile,
|
|
'city': value.city,
|
|
'country_id': value.country_id.display_name,
|
|
'website': value.website,
|
|
'email': value.email,
|
|
'vat': value.vat,
|
|
'vat_label': value.country_id.vat_label or _('VAT'),
|
|
'fields': opf,
|
|
'object': value,
|
|
'options': options
|
|
}
|
|
return self.env['ir.qweb']._render('base.contact', val, minimal_qcontext=True)
|
|
|
|
|
|
class QwebView(models.AbstractModel):
|
|
_name = 'ir.qweb.field.qweb'
|
|
_description = 'Qweb Field qweb'
|
|
_inherit = 'ir.qweb.field.many2one'
|
|
|
|
@api.model
|
|
def record_to_html(self, record, field_name, options):
|
|
view = record[field_name]
|
|
if not view:
|
|
return ''
|
|
|
|
if view._name != "ir.ui.view":
|
|
_logger.warning("%s.%s must be a 'ir.ui.view', got %r.", record, field_name, view._name)
|
|
return ''
|
|
|
|
return self.env['ir.qweb']._render(view.id, options.get('values', {}))
|