839 lines
33 KiB
Python
839 lines
33 KiB
Python
|
# -*- coding: utf-8 -*-
|
||
|
import base64
|
||
|
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
|
||
|
|
||
|
_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: # 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 "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', {}))
|