diff --git a/openerp/addons/base/ir/ir_qweb.py b/openerp/addons/base/ir/ir_qweb.py index 6af2d5a5d9d..3cafdbbe526 100644 --- a/openerp/addons/base/ir/ir_qweb.py +++ b/openerp/addons/base/ir/ir_qweb.py @@ -2,16 +2,18 @@ import cStringIO import json import datetime -import dateutil +import dateutil.relativedelta import logging import re import urllib import xml # FIXME use lxml and etree import Image +import math import werkzeug.utils from openerp.osv import osv, orm, fields +import openerp.tools _logger = logging.getLogger(__name__) @@ -58,10 +60,14 @@ class QWebException(Exception): #SAFE = ["_name"] class QWebContext(dict): - def __init__(self, data, undefined_handler=None, loader=None, templates=None): + def __init__(self, cr, uid, data, undefined_handler=None, loader=None, + templates=None, context=None): + self.cr = cr + self.uid = uid self.loader = loader self.undefined_handler = undefined_handler self.templates = templates or {} + self.context = context dic = BUILTINS.copy() dic.update(data) super(QWebContext, self).__init__(dic) @@ -97,10 +103,11 @@ class QWebContext(dict): return eval(expr, None, self) def copy(self): - return QWebContext(dict.copy(self), + return QWebContext(self.cr, self.uid, dict.copy(self), undefined_handler=self.undefined_handler, loader=self.loader, - templates=self.templates) + templates=self.templates, + context=self.context) def __copy__(self): return self.copy() @@ -235,11 +242,13 @@ class QWeb(orm.AbstractModel): else: return 0 - def render(self, tname, v=None, out=None, loader=None, undefined_handler=None): + def render(self, cr, uid, tname, v=None, loader=None, + undefined_handler=None, context=None): if v is None: v = {} if not isinstance(v, QWebContext): - v = QWebContext(v, undefined_handler=undefined_handler, loader=loader) + v = QWebContext(cr, uid, v, undefined_handler=undefined_handler, + loader=loader, context=context) v['__template__'] = tname stack = v.get('__stack__', []) if stack: @@ -419,7 +428,7 @@ class QWeb(orm.AbstractModel): d = v if 'import' in t_att else v.copy() d[0] = self.render_element(e, t_att, g_att, d) - return self.render(self.eval_format(t_att["call"], d), d) + return self.render(None, None, self.eval_format(t_att["call"], d), d) def render_tag_set(self, e, t_att, g_att, v): if "value" in t_att: @@ -448,8 +457,8 @@ class QWeb(orm.AbstractModel): converter = self.get_converter_for(field_type) - return converter.to_html(record._cr, record._uid, field_name, record, options, - e, t_att, g_att, v) + return converter.to_html(v.cr, v.uid, field_name, record, options, + e, t_att, g_att, v, context=v.context) def get_converter_for(self, field_type): return self.pool.get('ir.qweb.field.' + field_type, @@ -469,7 +478,8 @@ class FieldConverter(osv.AbstractModel): _name = 'ir.qweb.field' def attributes(self, cr, uid, field_name, record, options, - source_element, g_att, t_att, qweb_context): + source_element, g_att, t_att, qweb_context, + context=None): """ Generates the metadata attributes (prefixed by ``data-oe-`` for the root node of the field conversion. Attribute values are escaped by the @@ -498,20 +508,20 @@ class FieldConverter(osv.AbstractModel): ('data-oe-expression', t_att['field']), ] - def value_to_html(self, cr, uid, value, column, options=None): + def value_to_html(self, cr, uid, value, column, options=None, context=None): """ Converts a single value to its HTML version/output """ return werkzeug.utils.escape(value) - def record_to_html(self, cr, uid, field_name, record, column, options=None): + def record_to_html(self, cr, uid, field_name, record, column, options=None, context=None): """ Converts the specified field of the browse_record ``record`` to HTML """ return self.value_to_html( - cr, uid, record[field_name], column, options=None) + cr, uid, record[field_name], column, options=None, context=context) def to_html(self, cr, uid, field_name, record, options, - source_element, t_att, g_att, qweb_context): + source_element, t_att, g_att, qweb_context, context=None): """ Converts a ``t-field`` to its HTML output. A ``t-field`` may be extended by a ``t-field-options``, which is a JSON-serialized mapping of configuration values. @@ -524,7 +534,7 @@ class FieldConverter(osv.AbstractModel): content = self.record_to_html( cr, uid, field_name, record, record._model._all_columns[field_name].column, - options) + options, context=context) except Exception: _logger.warning("Could not get field %s for model %s", field_name, record._model._name, exc_info=True) @@ -549,22 +559,86 @@ class FieldConverter(osv.AbstractModel): return self.qweb_object().render_element( source_element, t_att, g_att, qweb_context, content or '') + def user_lang(self, cr, uid, context): + """ + Fetches the res.lang object 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: res.lang browse_record + """ + if context is None: context = {} + + lang_code = context.get('lang') or 'en_US' + Lang = self.pool['res.lang'] + + lang_ids = Lang.search(cr, uid, [('code', '=', lang_code)], context=context) \ + or Lang.search(cr, uid, [('code', '=', 'en_US')], context=context) + + return Lang.browse(cr, uid, lang_ids[0], context=context) + class FloatConverter(osv.AbstractModel): _name = 'ir.qweb.field.float' _inherit = 'ir.qweb.field' - def value_to_html(self, cr, uid, value, column, options=None): - width, precision = column.digits or (None, None) - fmt = '{value}' if precision is None else '{value:.{precision}f}' + def precision(self, cr, uid, column, options=None, context=None): + _, precision = column.digits or (None, None) + return precision - return werkzeug.utils.escape( - fmt.format(value=value, width=width, precision=precision, )) + def value_to_html(self, cr, uid, value, column, options=None, context=None): + precision = self.precision(cr, uid, column, options=options, context=context) + fmt = '%f' if precision is None else '%.{precision}f' + + lang = self.user_lang(cr, uid, context) + + formatted = lang.format(fmt.format(precision=precision), + value, grouping=True) + # %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 not precision: + formatted = re.sub(r'(?:(0|\d+?)0+)$', r'\1', formatted) + return werkzeug.utils.escape(formatted) + +class DateConverter(osv.AbstractModel): + _name = 'ir.qweb.field.date' + _inherit = 'ir.qweb.field' + + def value_to_html(self, cr, uid, value, column, options=None, context=None): + lang = self.user_lang(cr, uid, context=context) + + parse_format = openerp.tools.DEFAULT_SERVER_DATE_FORMAT + out_format = lang.date_format.encode('utf-8') + + if isinstance(value, basestring): + value = datetime.datetime.strptime(value, parse_format) + + return value.strftime(out_format) + +class DateTimeConverter(osv.AbstractModel): + _name = 'ir.qweb.field.datetime' + _inherit = 'ir.qweb.field' + + def value_to_html(self, cr, uid, value, column, options=None, context=None): + lang = self.user_lang(cr, uid, context=context) + + parse_format = openerp.tools.DEFAULT_SERVER_DATETIME_FORMAT + out_format = (u"%s %s" % (lang.date_format, lang.time_format)).encode('utf-8') + + if isinstance(value, basestring): + value = datetime.datetime.strptime(value, parse_format) + + value = column.context_timestamp( + cr, uid, timestamp=value, context=context) + + return value.strftime(out_format) class TextConverter(osv.AbstractModel): _name = 'ir.qweb.field.text' _inherit = 'ir.qweb.field' - def value_to_html(self, cr, uid, value, column, options=None): + def value_to_html(self, cr, uid, value, column, options=None, context=None): """ Escapes the value and converts newlines to br. This is bullshit. """ @@ -574,8 +648,7 @@ class SelectionConverter(osv.AbstractModel): _name = 'ir.qweb.field.selection' _inherit = 'ir.qweb.field' - def record_to_html(self, cr, uid, field_name, record, column, options=None): - # FIXME: context + def record_to_html(self, cr, uid, field_name, record, column, options=None, context=None): value = record[field_name] selection = dict(fields.selection.reify( cr, uid, record._model, column)) @@ -586,7 +659,7 @@ class ManyToOneConverter(osv.AbstractModel): _name = 'ir.qweb.field.many2one' _inherit = 'ir.qweb.field' - def value_to_html(self, cr, uid, value, column, options=None): + def value_to_html(self, cr, uid, value, column, options=None, context=None): # value may be a browse_null if not value: return '' return werkzeug.utils.escape(value.name_get()[0][1]).replace('\n', '
\n') @@ -595,7 +668,7 @@ class HTMLConverter(osv.AbstractModel): _name = 'ir.qweb.field.html' _inherit = 'ir.qweb.field' - def value_to_html(self, cr, uid, value, column, options=None): + def value_to_html(self, cr, uid, value, column, options=None, context=None): return value class ImageConverter(osv.AbstractModel): @@ -610,7 +683,7 @@ class ImageConverter(osv.AbstractModel): _name = 'ir.qweb.field.image' _inherit = 'ir.qweb.field' - def value_to_html(self, cr, uid, value, column, options=None): + def value_to_html(self, cr, uid, value, column, options=None, context=None): try: image = Image.open(cStringIO.StringIO(value.decode('base64'))) image.verify() @@ -625,6 +698,10 @@ class MonetaryConverter(osv.AbstractModel): """ ``monetary`` converter, has a mandatory option ``display_currency``. + 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 ``_qweb_context`` key. @@ -633,32 +710,45 @@ class MonetaryConverter(osv.AbstractModel): _inherit = 'ir.qweb.field' def to_html(self, cr, uid, field_name, record, options, - source_element, t_att, g_att, qweb_context): + source_element, t_att, g_att, qweb_context, context=None): options['_qweb_context'] = qweb_context return super(MonetaryConverter, self).to_html( cr, uid, field_name, record, options, - source_element, t_att, g_att, qweb_context) + source_element, t_att, g_att, qweb_context, context=context) - def record_to_html(self, cr, uid, field_name, record, column, options): + def record_to_html(self, cr, uid, field_name, record, column, options, context=None): Currency = self.pool['res.currency'] display = self.display_currency(cr, uid, options) - symbol_pre = symbol_post = space_pre = space_post = u'' - if display.position == 'before': - space_pre = u' ' - symbol_pre = display.symbol - else: - space_post = u' ' - symbol_post = display.symbol + # 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). + # + # The log10 of the rounding should be the number of digits involved if + # negative, if positive clamp to 0 digits and call it a day. + # nb: int() ~ floor(), we want nearest rounding instead + precision = int(round(math.log10(display.rounding))) + fmt = "%.{0}f".format(-precision if precision < 0 else 0) - return u'{symbol_pre}{space_pre}' \ - u'{0}' \ - u'{space_post}{symbol_post}'.format( - Currency.round(cr, uid, display, record[field_name]), - space_pre=space_pre, - symbol_pre=symbol_pre, - space_post=space_post, - symbol_post=symbol_post,) + lang = self.user_lang(cr, uid, context=context) + formatted_amount = lang.format( + fmt, Currency.round(cr, uid, display, record[field_name]), + grouping=True, monetary=True) + + pre = post = u'' + if display.position == 'before': + pre = u'{symbol} ' + else: + post = u' {symbol}' + + return u'{pre}{0}{post}'.format( + formatted_amount, + pre=pre, post=post, + ).format( + symbol=display.symbol, + ) def display_currency(self, cr, uid, options): return self.qweb_object().eval_object( diff --git a/openerp/addons/base/ir/ir_ui_view.py b/openerp/addons/base/ir/ir_ui_view.py index 6fa609826f4..5876d211380 100644 --- a/openerp/addons/base/ir/ir_ui_view.py +++ b/openerp/addons/base/ir/ir_ui_view.py @@ -775,7 +775,7 @@ class view(osv.osv): for key in values: value = isinstance(values, (dict,)) and values[key] or key if isinstance(value, (browse_record,)): - if value.__dict__.get('_uid') == openerp.SUPERUSER_ID and uid != openerp.SUPERUSER_ID: + if getattr(value, '_uid', None) == openerp.SUPERUSER_ID and uid != openerp.SUPERUSER_ID: message = 'SUPERUSER_ID Access used for rendering "%s" in a xml view: %s' % (key, id_or_xml_id,) _logger.warn(message) elif isinstance(value, (dict, list, browse_record_list,)): @@ -786,8 +786,9 @@ class view(osv.osv): return self.read_template(cr, uid, name, context=context) return self.pool[engine].render( - id_or_xml_id, values, - loader=loader, undefined_handler=lambda key, v: None) + cr, uid, id_or_xml_id, values, + loader=loader, undefined_handler=lambda key, v: None, + context=context) # maybe used to print the workflow ? diff --git a/openerp/addons/base/res/res_lang.py b/openerp/addons/base/res/res_lang.py index 160e86fd8d2..d33ce0bf14b 100644 --- a/openerp/addons/base/res/res_lang.py +++ b/openerp/addons/base/res/res_lang.py @@ -194,30 +194,24 @@ class lang(osv.osv): def format(self, cr, uid, ids, percent, value, grouping=False, monetary=False, context=None): """ Format() will return the language-specific output for float values""" - if percent[0] != '%': raise ValueError("format() must be given exactly one %char format specifier") - lang_grouping, thousands_sep, decimal_point = self._lang_data_get(cr, uid, ids[0], monetary) - eval_lang_grouping = eval(lang_grouping) - formatted = percent % value + # floats and decimal ints need special action! - if percent[-1] in 'eEfFgG': - seps = 0 - parts = formatted.split('.') + if grouping: + lang_grouping, thousands_sep, decimal_point = \ + self._lang_data_get(cr, uid, ids[0], monetary) + eval_lang_grouping = eval(lang_grouping) - if grouping: - parts[0], seps = intersperse(parts[0], eval_lang_grouping, thousands_sep) + if percent[-1] in 'eEfFgG': + parts = formatted.split('.') + parts[0], _ = intersperse(parts[0], eval_lang_grouping, thousands_sep) - formatted = decimal_point.join(parts) - while seps: - sp = formatted.find(' ') - if sp == -1: break - formatted = formatted[:sp] + formatted[sp+1:] - seps -= 1 - elif percent[-1] in 'diu': - if grouping: + formatted = decimal_point.join(parts) + + elif percent[-1] in 'diu': formatted = intersperse(formatted, eval_lang_grouping, thousands_sep)[0] return formatted diff --git a/openerp/addons/base/security/ir.model.access.csv b/openerp/addons/base/security/ir.model.access.csv index 1401b188e0a..4a5331e87c3 100644 --- a/openerp/addons/base/security/ir.model.access.csv +++ b/openerp/addons/base/security/ir.model.access.csv @@ -114,6 +114,8 @@ access_ir_qweb,access_ir_qweb,model_ir_qweb,,0,0,0,0 access_ir_qweb_field,access_ir_qweb_field,model_ir_qweb_field,,0,0,0,0 access_ir_qweb_field_float,access_ir_qweb_field_float,model_ir_qweb_field_float,,0,0,0,0 +access_ir_qweb_field_date,access_ir_qweb_field_date,model_ir_qweb_field_date,,0,0,0,0 +access_ir_qweb_field_datetime,access_ir_qweb_field_datetime,model_ir_qweb_field_datetime,,0,0,0,0 access_ir_qweb_field_text,access_ir_qweb_field_text,model_ir_qweb_field_text,,0,0,0,0 access_ir_qweb_field_selection,access_ir_qweb_field_selection,model_ir_qweb_field_selection,,0,0,0,0 access_ir_qweb_field_many2one,access_ir_qweb_field_many2one,model_ir_qweb_field_many2one,,0,0,0,0 diff --git a/openerp/tests/addons/test_converter/tests/test_html.py b/openerp/tests/addons/test_converter/tests/test_html.py index d433c814958..6d1f1cd9a56 100644 --- a/openerp/tests/addons/test_converter/tests/test_html.py +++ b/openerp/tests/addons/test_converter/tests/test_html.py @@ -53,6 +53,12 @@ class TestIntegerExport(TestBasicExport): self.assertEqual(value, "42") class TestFloatExport(TestBasicExport): + def setUp(self): + super(TestFloatExport, self).setUp() + self.registry('res.lang').write(self.cr, self.uid, [1], { + 'grouping': '[3,0]' + }) + def test_float(self): converter = self.get_converter('float') @@ -65,6 +71,9 @@ class TestFloatExport(TestBasicExport): value = converter(42.01234) self.assertEqual(value, "42.01234") + value = converter(1234567.89) + self.assertEqual(value, '1,234,567.89') + def test_numeric(self): converter = self.get_converter('numeric') @@ -98,7 +107,7 @@ class TestCurrencyExport(TestExport): self.cr, self.uid, 'value', obj, options, doc.createElement('span'), {'field': 'obj.value', 'field-options': json.dumps(options)}, - '', ir_qweb.QWebContext({'obj': obj, 'c2': dest, })) + '', ir_qweb.QWebContext(self.cr, self.uid, {'obj': obj, 'c2': dest, })) return converted def test_currency_post(self): @@ -263,5 +272,30 @@ class TestHTMLExport(TestBasicExport): value = converter(input) self.assertEqual(value, input) +class TestDatetimeExport(TestBasicExport): + def setUp(self): + super(TestDatetimeExport, self).setUp() + # set user tz to known value + Users = self.registry('res.users') + Users.write(self.cr, self.uid, self.uid, { + 'tz': 'Pacific/Niue' + }, context=None) + + def test_date(self): + converter = self.get_converter('date') + + value = converter('2011-05-03') + + # default lang/format is US + self.assertEqual(value, '05/03/2011') + + def test_datetime(self): + converter = self.get_converter('datetime') + + value = converter('2011-05-03 11:12:13') + + # default lang/format is US + self.assertEqual(value, '05/03/2011 00:12:13') + # o2m, m2m? # reference? diff --git a/openerp/tests/test_qweb.py b/openerp/tests/test_qweb.py index 662946c175e..48e8dc377c5 100644 --- a/openerp/tests/test_qweb.py +++ b/openerp/tests/test_qweb.py @@ -4,7 +4,6 @@ from xml.dom import minidom as dom import common from openerp.addons.base.ir import ir_qweb -import openerp.addons.base.ir.ir_qweb impl = dom.getDOMImplementation() document = impl.createDocument(None, None, None) @@ -14,6 +13,9 @@ class TestQWebTField(common.TransactionCase): super(TestQWebTField, self).setUp() self.engine = self.registry('ir.qweb') + def context(self, values): + return ir_qweb.QWebContext(self.cr, self.uid, values) + def test_trivial(self): field = document.createElement('span') field.setAttribute('t-field', u'company.name') @@ -22,10 +24,8 @@ class TestQWebTField(common.TransactionCase): company_id = Companies.create(self.cr, self.uid, { 'name': "My Test Company" }) - root_company = Companies.browse(self.cr, self.uid, company_id) - - result = self.engine.render_node(field, ir_qweb.QWebContext({ - 'company': root_company, + result = self.engine.render_node(field, self.context({ + 'company': Companies.browse(self.cr, self.uid, company_id), })) self.assertEqual( @@ -45,11 +45,10 @@ class TestQWebTField(common.TransactionCase): company_id = Companies.create(self.cr, self.uid, { 'name': s, }) - root_company = Companies.browse(self.cr, self.uid, company_id) - - result = self.engine.render_node(field, ir_qweb.QWebContext({ - 'company': root_company, + result = self.engine.render_node(field, self.context({ + 'company': Companies.browse(self.cr, self.uid, company_id), })) + self.assertEqual( result, '