1# -*- coding: utf-8 -*-
2# Part of Odoo. See LICENSE file for full copyright and licensing details.
3
4import logging
5import math
6import re
7import time
8import traceback
9
10from odoo import api, fields, models, tools, _
11
12_logger = logging.getLogger(__name__)
13
14try:
15    from num2words import num2words
16except ImportError:
17    _logger.warning("The num2words python library is not installed, amount-to-text features won't be fully available.")
18    num2words = None
19
20CURRENCY_DISPLAY_PATTERN = re.compile(r'(\w+)\s*(?:\((.*)\))?')
21
22
23class Currency(models.Model):
24    _name = "res.currency"
25    _description = "Currency"
26    _order = 'active desc, name'
27
28    # Note: 'code' column was removed as of v6.0, the 'name' should now hold the ISO code.
29    name = fields.Char(string='Currency', size=3, required=True, help="Currency Code (ISO 4217)")
30    symbol = fields.Char(help="Currency sign, to be used when printing amounts.", required=True)
31    rate = fields.Float(compute='_compute_current_rate', string='Current Rate', digits=0,
32                        help='The rate of the currency to the currency of rate 1.')
33    rate_ids = fields.One2many('res.currency.rate', 'currency_id', string='Rates')
34    rounding = fields.Float(string='Rounding Factor', digits=(12, 6), default=0.01)
35    decimal_places = fields.Integer(compute='_compute_decimal_places', store=True)
36    active = fields.Boolean(default=True)
37    position = fields.Selection([('after', 'After Amount'), ('before', 'Before Amount')], default='after',
38        string='Symbol Position', help="Determines where the currency symbol should be placed after or before the amount.")
39    date = fields.Date(compute='_compute_date')
40    currency_unit_label = fields.Char(string="Currency Unit", help="Currency Unit Name")
41    currency_subunit_label = fields.Char(string="Currency Subunit", help="Currency Subunit Name")
42
43    _sql_constraints = [
44        ('unique_name', 'unique (name)', 'The currency code must be unique!'),
45        ('rounding_gt_zero', 'CHECK (rounding>0)', 'The rounding factor must be greater than 0!')
46    ]
47
48    def _get_rates(self, company, date):
49        if not self.ids:
50            return {}
51        self.env['res.currency.rate'].flush(['rate', 'currency_id', 'company_id', 'name'])
52        query = """SELECT c.id,
53                          COALESCE((SELECT r.rate FROM res_currency_rate r
54                                  WHERE r.currency_id = c.id AND r.name <= %s
55                                    AND (r.company_id IS NULL OR r.company_id = %s)
56                               ORDER BY r.company_id, r.name DESC
57                                  LIMIT 1), 1.0) AS rate
58                   FROM res_currency c
59                   WHERE c.id IN %s"""
60        self._cr.execute(query, (date, company.id, tuple(self.ids)))
61        currency_rates = dict(self._cr.fetchall())
62        return currency_rates
63
64    @api.depends('rate_ids.rate')
65    def _compute_current_rate(self):
66        date = self._context.get('date') or fields.Date.today()
67        company = self.env['res.company'].browse(self._context.get('company_id')) or self.env.company
68        # the subquery selects the last rate before 'date' for the given currency/company
69        currency_rates = self._get_rates(company, date)
70        for currency in self:
71            currency.rate = currency_rates.get(currency.id) or 1.0
72
73    @api.depends('rounding')
74    def _compute_decimal_places(self):
75        for currency in self:
76            if 0 < currency.rounding < 1:
77                currency.decimal_places = int(math.ceil(math.log10(1/currency.rounding)))
78            else:
79                currency.decimal_places = 0
80
81    @api.depends('rate_ids.name')
82    def _compute_date(self):
83        for currency in self:
84            currency.date = currency.rate_ids[:1].name
85
86    @api.model
87    def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None):
88        results = super(Currency, self)._name_search(name, args, operator=operator, limit=limit, name_get_uid=name_get_uid)
89        if not results:
90            name_match = CURRENCY_DISPLAY_PATTERN.match(name)
91            if name_match:
92                results = super(Currency, self)._name_search(name_match.group(1), args, operator=operator, limit=limit, name_get_uid=name_get_uid)
93        return results
94
95    def name_get(self):
96        return [(currency.id, tools.ustr(currency.name)) for currency in self]
97
98    def amount_to_text(self, amount):
99        self.ensure_one()
100        def _num2words(number, lang):
101            try:
102                return num2words(number, lang=lang).title()
103            except NotImplementedError:
104                return num2words(number, lang='en').title()
105
106        if num2words is None:
107            logging.getLogger(__name__).warning("The library 'num2words' is missing, cannot render textual amounts.")
108            return ""
109
110        formatted = "%.{0}f".format(self.decimal_places) % amount
111        parts = formatted.partition('.')
112        integer_value = int(parts[0])
113        fractional_value = int(parts[2] or 0)
114
115        lang = tools.get_lang(self.env)
116        amount_words = tools.ustr('{amt_value} {amt_word}').format(
117                        amt_value=_num2words(integer_value, lang=lang.iso_code),
118                        amt_word=self.currency_unit_label,
119                        )
120        if not self.is_zero(amount - integer_value):
121            amount_words += ' ' + _('and') + tools.ustr(' {amt_value} {amt_word}').format(
122                        amt_value=_num2words(fractional_value, lang=lang.iso_code),
123                        amt_word=self.currency_subunit_label,
124                        )
125        return amount_words
126
127    def round(self, amount):
128        """Return ``amount`` rounded  according to ``self``'s rounding rules.
129
130           :param float amount: the amount to round
131           :return: rounded float
132        """
133        self.ensure_one()
134        return tools.float_round(amount, precision_rounding=self.rounding)
135
136    def compare_amounts(self, amount1, amount2):
137        """Compare ``amount1`` and ``amount2`` after rounding them according to the
138           given currency's precision..
139           An amount is considered lower/greater than another amount if their rounded
140           value is different. This is not the same as having a non-zero difference!
141
142           For example 1.432 and 1.431 are equal at 2 digits precision,
143           so this method would return 0.
144           However 0.006 and 0.002 are considered different (returns 1) because
145           they respectively round to 0.01 and 0.0, even though
146           0.006-0.002 = 0.004 which would be considered zero at 2 digits precision.
147
148           :param float amount1: first amount to compare
149           :param float amount2: second amount to compare
150           :return: (resp.) -1, 0 or 1, if ``amount1`` is (resp.) lower than,
151                    equal to, or greater than ``amount2``, according to
152                    ``currency``'s rounding.
153
154           With the new API, call it like: ``currency.compare_amounts(amount1, amount2)``.
155        """
156        self.ensure_one()
157        return tools.float_compare(amount1, amount2, precision_rounding=self.rounding)
158
159    def is_zero(self, amount):
160        """Returns true if ``amount`` is small enough to be treated as
161           zero according to current currency's rounding rules.
162           Warning: ``is_zero(amount1-amount2)`` is not always equivalent to
163           ``compare_amounts(amount1,amount2) == 0``, as the former will round after
164           computing the difference, while the latter will round before, giving
165           different results for e.g. 0.006 and 0.002 at 2 digits precision.
166
167           :param float amount: amount to compare with currency's zero
168
169           With the new API, call it like: ``currency.is_zero(amount)``.
170        """
171        self.ensure_one()
172        return tools.float_is_zero(amount, precision_rounding=self.rounding)
173
174    @api.model
175    def _get_conversion_rate(self, from_currency, to_currency, company, date):
176        currency_rates = (from_currency + to_currency)._get_rates(company, date)
177        res = currency_rates.get(to_currency.id) / currency_rates.get(from_currency.id)
178        return res
179
180    def _convert(self, from_amount, to_currency, company, date, round=True):
181        """Returns the converted amount of ``from_amount``` from the currency
182           ``self`` to the currency ``to_currency`` for the given ``date`` and
183           company.
184
185           :param company: The company from which we retrieve the convertion rate
186           :param date: The nearest date from which we retriev the conversion rate.
187           :param round: Round the result or not
188        """
189        self, to_currency = self or to_currency, to_currency or self
190        assert self, "convert amount from unknown currency"
191        assert to_currency, "convert amount to unknown currency"
192        assert company, "convert amount from unknown company"
193        assert date, "convert amount from unknown date"
194        # apply conversion rate
195        if self == to_currency:
196            to_amount = from_amount
197        else:
198            to_amount = from_amount * self._get_conversion_rate(self, to_currency, company, date)
199        # apply rounding
200        return to_currency.round(to_amount) if round else to_amount
201
202    @api.model
203    def _compute(self, from_currency, to_currency, from_amount, round=True):
204        _logger.warning('The `_compute` method is deprecated. Use `_convert` instead')
205        date = self._context.get('date') or fields.Date.today()
206        company = self.env['res.company'].browse(self._context.get('company_id')) or self.env.company
207        return from_currency._convert(from_amount, to_currency, company, date)
208
209    def compute(self, from_amount, to_currency, round=True):
210        _logger.warning('The `compute` method is deprecated. Use `_convert` instead')
211        date = self._context.get('date') or fields.Date.today()
212        company = self.env['res.company'].browse(self._context.get('company_id')) or self.env.company
213        return self._convert(from_amount, to_currency, company, date)
214
215    def _select_companies_rates(self):
216        return """
217            SELECT
218                r.currency_id,
219                COALESCE(r.company_id, c.id) as company_id,
220                r.rate,
221                r.name AS date_start,
222                (SELECT name FROM res_currency_rate r2
223                 WHERE r2.name > r.name AND
224                       r2.currency_id = r.currency_id AND
225                       (r2.company_id is null or r2.company_id = c.id)
226                 ORDER BY r2.name ASC
227                 LIMIT 1) AS date_end
228            FROM res_currency_rate r
229            JOIN res_company c ON (r.company_id is null or r.company_id = c.id)
230        """
231
232
233class CurrencyRate(models.Model):
234    _name = "res.currency.rate"
235    _description = "Currency Rate"
236    _order = "name desc"
237
238    name = fields.Date(string='Date', required=True, index=True,
239                           default=lambda self: fields.Date.today())
240    rate = fields.Float(digits=0, default=1.0, help='The rate of the currency to the currency of rate 1')
241    currency_id = fields.Many2one('res.currency', string='Currency', readonly=True, required=True, ondelete="cascade")
242    company_id = fields.Many2one('res.company', string='Company',
243                                 default=lambda self: self.env.company)
244
245    _sql_constraints = [
246        ('unique_name_per_day', 'unique (name,currency_id,company_id)', 'Only one currency rate per day allowed!'),
247        ('currency_rate_check', 'CHECK (rate>0)', 'The currency rate must be strictly positive.'),
248    ]
249
250    @api.model
251    def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None):
252        if operator in ['=', '!=']:
253            try:
254                date_format = '%Y-%m-%d'
255                if self._context.get('lang'):
256                    lang_id = self.env['res.lang']._search([('code', '=', self._context['lang'])], access_rights_uid=name_get_uid)
257                    if lang_id:
258                        date_format = self.browse(lang_id).date_format
259                name = time.strftime('%Y-%m-%d', time.strptime(name, date_format))
260            except ValueError:
261                try:
262                    args.append(('rate', operator, float(name)))
263                except ValueError:
264                    return []
265                name = ''
266                operator = 'ilike'
267        return super(CurrencyRate, self)._name_search(name, args=args, operator=operator, limit=limit, name_get_uid=name_get_uid)
268