# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import logging import math from lxml import etree from odoo import api, fields, models, tools, _ from odoo.exceptions import UserError, ValidationError from odoo.tools import parse_date _logger = logging.getLogger(__name__) try: from num2words import num2words except ImportError: _logger.warning("The num2words python library is not installed, amount-to-text features won't be fully available.") num2words = None class Currency(models.Model): _name = "res.currency" _description = "Currency" _rec_names_search = ['name', 'full_name'] _order = 'active desc, name' # Note: 'code' column was removed as of v6.0, the 'name' should now hold the ISO code. name = fields.Char(string='Currency', size=3, required=True, help="Currency Code (ISO 4217)") full_name = fields.Char(string='Name') symbol = fields.Char(help="Currency sign, to be used when printing amounts.", required=True) rate = fields.Float(compute='_compute_current_rate', string='Current Rate', digits=0, help='The rate of the currency to the currency of rate 1.') inverse_rate = fields.Float(compute='_compute_current_rate', digits=0, readonly=True, help='The currency of rate 1 to the rate of the currency.') rate_string = fields.Char(compute='_compute_current_rate') rate_ids = fields.One2many('res.currency.rate', 'currency_id', string='Rates') rounding = fields.Float(string='Rounding Factor', digits=(12, 6), default=0.01, help='Amounts in this currency are rounded off to the nearest multiple of the rounding factor.') decimal_places = fields.Integer(compute='_compute_decimal_places', store=True, help='Decimal places taken into account for operations on amounts in this currency. It is determined by the rounding factor.') active = fields.Boolean(default=True) position = fields.Selection([('after', 'After Amount'), ('before', 'Before Amount')], default='after', string='Symbol Position', help="Determines where the currency symbol should be placed after or before the amount.") date = fields.Date(compute='_compute_date') currency_unit_label = fields.Char(string="Currency Unit") currency_subunit_label = fields.Char(string="Currency Subunit") is_current_company_currency = fields.Boolean(compute='_compute_is_current_company_currency') _sql_constraints = [ ('unique_name', 'unique (name)', 'The currency code must be unique!'), ('rounding_gt_zero', 'CHECK (rounding>0)', 'The rounding factor must be greater than 0!') ] @api.model_create_multi def create(self, vals_list): res = super().create(vals_list) self._toggle_group_multi_currency() # Currency info is cached to reduce the number of SQL queries when building the session # info. See `ir_http.get_currencies`. self.env.registry.clear_cache() return res def unlink(self): res = super().unlink() self._toggle_group_multi_currency() # Currency info is cached to reduce the number of SQL queries when building the session # info. See `ir_http.get_currencies`. self.env.registry.clear_cache() return res def write(self, vals): res = super().write(vals) if vals.keys() & {'active', 'digits', 'position', 'symbol'}: # Currency info is cached to reduce the number of SQL queries when building the session # info. See `ir_http.get_currencies`. self.env.registry.clear_cache() if 'active' not in vals: return res self._toggle_group_multi_currency() return res @api.model def _toggle_group_multi_currency(self): """ Automatically activate group_multi_currency if there is more than 1 active currency; deactivate it otherwise """ active_currency_count = self.search_count([('active', '=', True)]) if active_currency_count > 1: self._activate_group_multi_currency() elif active_currency_count <= 1: self._deactivate_group_multi_currency() @api.model def _activate_group_multi_currency(self): group_user = self.env.ref('base.group_user', raise_if_not_found=False) group_mc = self.env.ref('base.group_multi_currency', raise_if_not_found=False) if group_user and group_mc: group_user.sudo()._apply_group(group_mc) @api.model def _deactivate_group_multi_currency(self): group_user = self.env.ref('base.group_user', raise_if_not_found=False) group_mc = self.env.ref('base.group_multi_currency', raise_if_not_found=False) if group_user and group_mc: group_user.sudo()._remove_group(group_mc.sudo()) @api.constrains('active') def _check_company_currency_stays_active(self): if self._context.get('install_mode') or self._context.get('force_deactivate'): # install_mode : At install, when this check is run, the "active" field of a currency added to a company will # still be evaluated as False, despite it's automatically set at True when added to the company. # force_deactivate : Allows deactivation of a currency in tests to enable non multi_currency behaviors return currencies = self.filtered(lambda c: not c.active) if self.env['res.company'].search([('currency_id', 'in', currencies.ids)]): raise UserError(_("This currency is set on a company and therefore cannot be deactivated.")) def _get_rates(self, company, date): if not self.ids: return {} self.env['res.currency.rate'].flush_model(['rate', 'currency_id', 'company_id', 'name']) query = """SELECT c.id, COALESCE((SELECT r.rate FROM res_currency_rate r WHERE r.currency_id = c.id AND r.name <= %s AND (r.company_id IS NULL OR r.company_id = %s) ORDER BY r.company_id, r.name DESC LIMIT 1), 1.0) AS rate FROM res_currency c WHERE c.id IN %s""" self._cr.execute(query, (date, company.root_id.id, tuple(self.ids))) currency_rates = dict(self._cr.fetchall()) return currency_rates @api.depends_context('company') def _compute_is_current_company_currency(self): for currency in self: currency.is_current_company_currency = self.env.company.root_id.currency_id == currency @api.depends('rate_ids.rate') @api.depends_context('to_currency', 'date', 'company', 'company_id') def _compute_current_rate(self): date = self._context.get('date') or fields.Date.context_today(self) company = self.env['res.company'].browse(self._context.get('company_id')) or self.env.company company = company.root_id to_currency = self.browse(self.env.context.get('to_currency')) or company.currency_id # the subquery selects the last rate before 'date' for the given currency/company currency_rates = (self + to_currency)._get_rates(self.env.company, date) for currency in self: currency.rate = currency_rates.get(currency.id) / currency_rates.get(to_currency.id) currency.inverse_rate = 1 / currency.rate if currency != company.currency_id: currency.rate_string = '1 %s = %.6f %s' % (to_currency.name, currency.rate, currency.name) else: currency.rate_string = '' @api.depends('rounding') def _compute_decimal_places(self): for currency in self: if 0 < currency.rounding < 1: currency.decimal_places = int(math.ceil(math.log10(1/currency.rounding))) else: currency.decimal_places = 0 @api.depends('rate_ids.name') def _compute_date(self): for currency in self: currency.date = currency.rate_ids[:1].name def amount_to_text(self, amount): self.ensure_one() def _num2words(number, lang): try: return num2words(number, lang=lang).title() except NotImplementedError: return num2words(number, lang='en').title() if num2words is None: logging.getLogger(__name__).warning("The library 'num2words' is missing, cannot render textual amounts.") return "" formatted = "%.{0}f".format(self.decimal_places) % amount parts = formatted.partition('.') integer_value = int(parts[0]) fractional_value = int(parts[2] or 0) lang = tools.get_lang(self.env) amount_words = tools.ustr('{amt_value} {amt_word}').format( amt_value=_num2words(integer_value, lang=lang.iso_code), amt_word=self.currency_unit_label, ) if not self.is_zero(amount - integer_value): amount_words += ' ' + _('and') + tools.ustr(' {amt_value} {amt_word}').format( amt_value=_num2words(fractional_value, lang=lang.iso_code), amt_word=self.currency_subunit_label, ) return amount_words def format(self, amount): """Return ``amount`` formatted according to ``self``'s rounding rules, symbols and positions. Also take care of removing the minus sign when 0.0 is negative :param float amount: the amount to round :return: formatted str """ self.ensure_one() return tools.format_amount(self.env, amount + 0.0, self) def round(self, amount): """Return ``amount`` rounded according to ``self``'s rounding rules. :param float amount: the amount to round :return: rounded float """ self.ensure_one() return tools.float_round(amount, precision_rounding=self.rounding) def compare_amounts(self, amount1, amount2): """Compare ``amount1`` and ``amount2`` after rounding them according to the given currency's precision.. An amount is considered lower/greater than another amount if their rounded value is different. This is not the same as having a non-zero difference! For example 1.432 and 1.431 are equal at 2 digits precision, so this method would return 0. However 0.006 and 0.002 are considered different (returns 1) because they respectively round to 0.01 and 0.0, even though 0.006-0.002 = 0.004 which would be considered zero at 2 digits precision. :param float amount1: first amount to compare :param float amount2: second amount to compare :return: (resp.) -1, 0 or 1, if ``amount1`` is (resp.) lower than, equal to, or greater than ``amount2``, according to ``currency``'s rounding. With the new API, call it like: ``currency.compare_amounts(amount1, amount2)``. """ self.ensure_one() return tools.float_compare(amount1, amount2, precision_rounding=self.rounding) def is_zero(self, amount): """Returns true if ``amount`` is small enough to be treated as zero according to current currency's rounding rules. Warning: ``is_zero(amount1-amount2)`` is not always equivalent to ``compare_amounts(amount1,amount2) == 0``, as the former will round after computing the difference, while the latter will round before, giving different results for e.g. 0.006 and 0.002 at 2 digits precision. :param float amount: amount to compare with currency's zero With the new API, call it like: ``currency.is_zero(amount)``. """ self.ensure_one() return tools.float_is_zero(amount, precision_rounding=self.rounding) @api.model def _get_conversion_rate(self, from_currency, to_currency, company=None, date=None): if from_currency == to_currency: return 1 company = company or self.env.company date = date or fields.Date.context_today(self) return from_currency.with_company(company).with_context(to_currency=to_currency.id, date=str(date)).inverse_rate def _convert(self, from_amount, to_currency, company=None, date=None, round=True): # noqa: A002 builtin-argument-shadowing """Returns the converted amount of ``from_amount``` from the currency ``self`` to the currency ``to_currency`` for the given ``date`` and company. :param company: The company from which we retrieve the convertion rate :param date: The nearest date from which we retriev the conversion rate. :param round: Round the result or not """ self, to_currency = self or to_currency, to_currency or self assert self, "convert amount from unknown currency" assert to_currency, "convert amount to unknown currency" # apply conversion rate if from_amount: to_amount = from_amount * self._get_conversion_rate(self, to_currency, company, date) else: return 0.0 # apply rounding return to_currency.round(to_amount) if round else to_amount def _select_companies_rates(self): return """ SELECT r.currency_id, COALESCE(r.company_id, c.id) as company_id, r.rate, r.name AS date_start, (SELECT name FROM res_currency_rate r2 WHERE r2.name > r.name AND r2.currency_id = r.currency_id AND (r2.company_id is null or r2.company_id = c.id) ORDER BY r2.name ASC LIMIT 1) AS date_end FROM res_currency_rate r JOIN res_company c ON (r.company_id is null or r.company_id = c.id) """ @api.model def _get_view_cache_key(self, view_id=None, view_type='form', **options): """The override of _get_view changing the rate field labels according to the company currency makes the view cache dependent on the company currency""" key = super()._get_view_cache_key(view_id, view_type, **options) return key + ((self.env['res.company'].browse(self._context.get('company_id')) or self.env.company.root_id).currency_id.name,) @api.model def _get_view(self, view_id=None, view_type='form', **options): arch, view = super()._get_view(view_id, view_type, **options) if view_type in ('tree', 'form'): currency_name = (self.env['res.company'].browse(self._context.get('company_id')) or self.env.company.root_id).currency_id.name fields_maps = [ [['company_rate', 'rate'], _('Unit per %s', currency_name)], [['inverse_company_rate', 'inverse_rate'], _('%s per Unit', currency_name)], ] for fnames, label in fields_maps: xpath_expression = '//tree//field[' + " or ".join(f"@name='{f}'" for f in fnames) + "][1]" node = arch.xpath(xpath_expression) if node: node[0].set('string', label) return arch, view class CurrencyRate(models.Model): _name = "res.currency.rate" _description = "Currency Rate" _rec_names_search = ['name', 'rate'] _order = "name desc" name = fields.Date(string='Date', required=True, index=True, default=fields.Date.context_today) rate = fields.Float( digits=0, group_operator="avg", help='The rate of the currency to the currency of rate 1', string='Technical Rate' ) company_rate = fields.Float( digits=0, compute="_compute_company_rate", inverse="_inverse_company_rate", group_operator="avg", help="The currency of rate 1 to the rate of the currency.", ) inverse_company_rate = fields.Float( digits=0, compute="_compute_inverse_company_rate", inverse="_inverse_inverse_company_rate", group_operator="avg", help="The rate of the currency to the currency of rate 1 ", ) currency_id = fields.Many2one('res.currency', string='Currency', readonly=True, required=True, ondelete="cascade") company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env.company.root_id) _sql_constraints = [ ('unique_name_per_day', 'unique (name,currency_id,company_id)', 'Only one currency rate per day allowed!'), ('currency_rate_check', 'CHECK (rate>0)', 'The currency rate must be strictly positive.'), ] def _sanitize_vals(self, vals): if 'inverse_company_rate' in vals and ('company_rate' in vals or 'rate' in vals): del vals['inverse_company_rate'] if 'company_rate' in vals and 'rate' in vals: del vals['company_rate'] return vals def write(self, vals): self.env['res.currency'].invalidate_model(['inverse_rate']) return super().write(self._sanitize_vals(vals)) @api.model_create_multi def create(self, vals_list): self.env['res.currency'].invalidate_model(['inverse_rate']) return super().create([self._sanitize_vals(vals) for vals in vals_list]) def _get_latest_rate(self): # Make sure 'name' is defined when creating a new rate. if not self.name: raise UserError(_("The name for the current rate is empty.\nPlease set it.")) return self.currency_id.rate_ids.sudo().filtered(lambda x: ( x.rate and x.company_id == (self.company_id or self.env.company.root_id) and x.name < (self.name or fields.Date.today()) )).sorted('name')[-1:] def _get_last_rates_for_companies(self, companies): return { company: company.currency_id.rate_ids.sudo().filtered(lambda x: ( x.rate and x.company_id == company or not x.company_id )).sorted('name')[-1:].rate or 1 for company in companies } @api.depends('currency_id', 'company_id', 'name') def _compute_rate(self): for currency_rate in self: currency_rate.rate = currency_rate.rate or currency_rate._get_latest_rate().rate or 1.0 @api.depends('rate', 'name', 'currency_id', 'company_id', 'currency_id.rate_ids.rate') @api.depends_context('company') def _compute_company_rate(self): last_rate = self.env['res.currency.rate']._get_last_rates_for_companies(self.company_id | self.env.company.root_id) for currency_rate in self: company = currency_rate.company_id or self.env.company.root_id currency_rate.company_rate = (currency_rate.rate or currency_rate._get_latest_rate().rate or 1.0) / last_rate[company] @api.onchange('company_rate') def _inverse_company_rate(self): last_rate = self.env['res.currency.rate']._get_last_rates_for_companies(self.company_id | self.env.company.root_id) for currency_rate in self: company = currency_rate.company_id or self.env.company.root_id currency_rate.rate = currency_rate.company_rate * last_rate[company] @api.depends('company_rate') def _compute_inverse_company_rate(self): for currency_rate in self: if not currency_rate.company_rate: currency_rate.company_rate = 1.0 currency_rate.inverse_company_rate = 1.0 / currency_rate.company_rate @api.onchange('inverse_company_rate') def _inverse_inverse_company_rate(self): for currency_rate in self: if not currency_rate.inverse_company_rate: currency_rate.inverse_company_rate = 1.0 currency_rate.company_rate = 1.0 / currency_rate.inverse_company_rate @api.onchange('company_rate') def _onchange_rate_warning(self): latest_rate = self._get_latest_rate() if latest_rate: diff = (latest_rate.rate - self.rate) / latest_rate.rate if abs(diff) > 0.2: return { 'warning': { 'title': _("Warning for %s", self.currency_id.name), 'message': _( "The new rate is quite far from the previous rate.\n" "Incorrect currency rates may cause critical problems, make sure the rate is correct!" ) } } @api.constrains('company_id') def _check_company_id(self): for rate in self: if rate.company_id.parent_id: raise ValidationError("Currency rates should only be created for main companies") @api.model def _name_search(self, name, domain=None, operator='ilike', limit=None, order=None): return super()._name_search(parse_date(self.env, name), domain, operator, limit, order) @api.model def _get_view_cache_key(self, view_id=None, view_type='form', **options): """The override of _get_view changing the rate field labels according to the company currency makes the view cache dependent on the company currency""" key = super()._get_view_cache_key(view_id, view_type, **options) return key + ((self.env['res.company'].browse(self._context.get('company_id')) or self.env.company.root_id).currency_id.name,) @api.model def _get_view(self, view_id=None, view_type='form', **options): arch, view = super()._get_view(view_id, view_type, **options) if view_type in ('tree'): names = { 'company_currency_name': (self.env['res.company'].browse(self._context.get('company_id')) or self.env.company.root_id).currency_id.name, 'rate_currency_name': self.env['res.currency'].browse(self._context.get('active_id')).name or 'Unit', } for field in [['company_rate', _('%(rate_currency_name)s per %(company_currency_name)s', **names)], ['inverse_company_rate', _('%(company_currency_name)s per %(rate_currency_name)s', **names)]]: node = arch.xpath("//tree//field[@name='%s']" % field[0]) if node: node[0].set('string', field[1]) return arch, view