Odoo18-Base/odoo/addons/base/models/res_lang.py
2025-01-06 10:57:38 +07:00

482 lines
21 KiB
Python

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import ast
import json
import locale
import logging
import re
from typing import Any, Literal
from odoo import api, fields, models, tools, _
from odoo.exceptions import UserError, ValidationError
from odoo.tools import OrderedSet, frozendict
_logger = logging.getLogger(__name__)
DEFAULT_DATE_FORMAT = '%m/%d/%Y'
DEFAULT_TIME_FORMAT = '%H:%M:%S'
DEFAULT_SHORT_TIME_FORMAT = '%H:%M'
class LangData(frozendict):
""" A ``dict``-like class which can access field value like a ``res.lang`` record.
Note: This data class cannot store data for fields with the same name as
``dict`` methods, like ``dict.keys``.
"""
__slots__ = ()
def __bool__(self) -> bool:
return bool(self.id)
def __getattr__(self, name: str) -> Any:
try:
return self[name]
except KeyError:
raise AttributeError
class LangDataDict(frozendict):
""" A ``dict`` of :class:`LangData` objects indexed by some key, which returns
a special dummy :class:`LangData` for missing keys.
"""
__slots__ = ()
def __missing__(self, key: Any) -> LangData:
some_lang = next(iter(self.values())) # should have at least one active language
return LangData(dict.fromkeys(some_lang, False))
class Lang(models.Model):
_name = "res.lang"
_description = "Languages"
_order = "active desc,name"
_allow_sudo_commands = False
_disallowed_datetime_patterns = list(tools.misc.DATETIME_FORMATS_MAP)
_disallowed_datetime_patterns.remove('%y') # this one is in fact allowed, just not good practice
name = fields.Char(required=True)
code = fields.Char(string='Locale Code', required=True, help='This field is used to set/get locales for user')
iso_code = fields.Char(string='ISO code', help='This ISO code is the name of po files to use for translations')
url_code = fields.Char('URL Code', required=True, help='The Lang Code displayed in the URL')
active = fields.Boolean()
direction = fields.Selection([('ltr', 'Left-to-Right'), ('rtl', 'Right-to-Left')], required=True, default='ltr')
date_format = fields.Char(string='Date Format', required=True, default=DEFAULT_DATE_FORMAT)
time_format = fields.Char(string='Time Format', required=True, default=DEFAULT_TIME_FORMAT)
short_time_format = fields.Char(string='Short Time Format', required=True, default=DEFAULT_SHORT_TIME_FORMAT, help="Time Format without seconds")
week_start = fields.Selection([('1', 'Monday'),
('2', 'Tuesday'),
('3', 'Wednesday'),
('4', 'Thursday'),
('5', 'Friday'),
('6', 'Saturday'),
('7', 'Sunday')], string='First Day of Week', required=True, default='7')
grouping = fields.Char(string='Separator Format', required=True, default='[]',
help="The Separator Format should be like [,n] where 0 < n :starting from Unit digit. "
"-1 will end the separation. e.g. [3,2,-1] will represent 106500 to be 1,06,500; "
"[1,2,-1] will represent it to be 106,50,0;[3] will represent it as 106,500. "
"Provided ',' as the thousand separator in each case.")
decimal_point = fields.Char(string='Decimal Separator', required=True, default='.', trim=False)
thousands_sep = fields.Char(string='Thousands Separator', default=',', trim=False)
@api.depends('code', 'flag_image')
def _compute_field_flag_image_url(self):
for lang in self:
if lang.flag_image:
lang.flag_image_url = f"/web/image/res.lang/{lang.id}/flag_image"
else:
lang.flag_image_url = f"/base/static/img/country_flags/{lang.code.lower().rsplit('_')[-1]}.png"
flag_image = fields.Image("Image")
flag_image_url = fields.Char(compute=_compute_field_flag_image_url)
_sql_constraints = [
('name_uniq', 'unique(name)', 'The name of the language must be unique!'),
('code_uniq', 'unique(code)', 'The code of the language must be unique!'),
('url_code_uniq', 'unique(url_code)', 'The URL code of the language must be unique!'),
]
@api.constrains('active')
def _check_active(self):
# do not check during installation
if self.env.registry.ready and not self.search_count([]):
raise ValidationError(_('At least one language must be active.'))
@api.constrains('time_format', 'date_format')
def _check_format(self):
for lang in self:
for pattern in lang._disallowed_datetime_patterns:
if (lang.time_format and pattern in lang.time_format) or \
(lang.date_format and pattern in lang.date_format):
raise ValidationError(_('Invalid date/time format directive specified. '
'Please refer to the list of allowed directives, '
'displayed when you edit a language.'))
@api.onchange('time_format', 'date_format')
def _onchange_format(self):
warning = {
'warning': {
'title': _("Using 24-hour clock format with AM/PM can cause issues."),
'message': _("Changing to 12-hour clock format instead."),
'type': 'notification'
}
}
for lang in self:
if lang.date_format and "%H" in lang.date_format and "%p" in lang.date_format:
lang.date_format = lang.date_format.replace("%H", "%I")
return warning
if lang.time_format and "%H" in lang.time_format and "%p" in lang.time_format:
lang.time_format = lang.time_format.replace("%H", "%I")
return warning
@api.constrains('grouping')
def _check_grouping(self):
warning = _('The Separator Format should be like [,n] where 0 < n :starting from Unit digit. '
'-1 will end the separation. e.g. [3,2,-1] will represent 106500 to be 1,06,500;'
'[1,2,-1] will represent it to be 106,50,0;[3] will represent it as 106,500. '
'Provided as the thousand separator in each case.')
for lang in self:
try:
if any(not isinstance(x, int) for x in json.loads(lang.grouping)):
raise ValidationError(warning)
except Exception:
raise ValidationError(warning)
def _register_hook(self):
# check that there is at least one active language
if not self.search_count([]):
_logger.error("No language is active.")
def _activate_lang(self, code):
""" Activate languages
:param code: code of the language to activate
:return: the language matching 'code' activated
"""
lang = self.with_context(active_test=False).search([('code', '=', code)])
if lang and not lang.active:
lang.active = True
return lang
def _create_lang(self, lang, lang_name=None):
""" Create the given language and make it active. """
# create the language with locale information
fail = True
iso_lang = tools.get_iso_codes(lang)
for ln in tools.translate.get_locales(lang):
try:
locale.setlocale(locale.LC_ALL, str(ln))
fail = False
break
except locale.Error:
continue
if fail:
lc = locale.getlocale()[0]
msg = 'Unable to get information for locale %s. Information from the default locale (%s) have been used.'
_logger.warning(msg, lang, lc)
if not lang_name:
lang_name = lang
def fix_xa0(s):
"""Fix badly-encoded non-breaking space Unicode character from locale.localeconv(),
coercing to utf-8, as some platform seem to output localeconv() in their system
encoding, e.g. Windows-1252"""
if s == '\xa0':
return '\xc2\xa0'
return s
def fix_datetime_format(format):
"""Python's strftime supports only the format directives
that are available on the platform's libc, so in order to
be 100% cross-platform we map to the directives required by
the C standard (1989 version), always available on platforms
with a C standard implementation."""
# For some locales, nl_langinfo returns a D_FMT/T_FMT that contains
# unsupported '%-' patterns, e.g. for cs_CZ
format = format.replace('%-', '%')
for pattern, replacement in tools.misc.DATETIME_FORMATS_MAP.items():
format = format.replace(pattern, replacement)
return str(format)
conv = locale.localeconv()
lang_info = {
'code': lang,
'iso_code': iso_lang,
'name': lang_name,
'active': True,
'date_format' : fix_datetime_format(locale.nl_langinfo(locale.D_FMT)),
'time_format' : fix_datetime_format(locale.nl_langinfo(locale.T_FMT)),
'decimal_point' : fix_xa0(str(conv['decimal_point'])),
'thousands_sep' : fix_xa0(str(conv['thousands_sep'])),
'grouping' : str(conv.get('grouping', [])),
}
try:
return self.create(lang_info)
finally:
tools.translate.resetlocale()
@api.model
def install_lang(self):
"""
This method is called from odoo/addons/base/data/res_lang_data.xml to load
some language and set it as the default for every partners. The
language is set via tools.config by the '_initialize_db' method on the
'db' object. This is a fragile solution and something else should be
found.
"""
# config['load_language'] is a comma-separated list or None
lang_code = (tools.config.get('load_language') or 'en_US').split(',')[0]
lang = self._activate_lang(lang_code) or self._create_lang(lang_code)
IrDefault = self.env['ir.default']
default_value = IrDefault._get('res.partner', 'lang')
if default_value is None:
IrDefault.set('res.partner', 'lang', lang_code)
# set language of main company, created directly by db bootstrap SQL
partner = self.env.company.partner_id
if not partner.lang:
partner.write({'lang': lang_code})
return True
# ------------------------------------------------------------
# cached methods for **active** languages
# ------------------------------------------------------------
@property
def CACHED_FIELDS(self) -> OrderedSet:
""" Return fields to cache for the active languages
Please promise all these fields don't depend on other models and context
and are not translated.
Warning: Don't add method names of ``dict`` to CACHED_FIELDS for sake of the
implementation of LangData
"""
return OrderedSet(['id', 'name', 'code', 'iso_code', 'url_code', 'active', 'direction', 'date_format',
'time_format', 'short_time_format', 'week_start', 'grouping', 'decimal_point', 'thousands_sep', 'flag_image_url'])
def _get_data(self, **kwargs: Any) -> LangData:
""" Get the language data for the given field value in kwargs
For example, get_data(code='en_US') will return the LangData
for the res.lang record whose 'code' field value is 'en_US'
:param dict kwargs: {field_name: field_value}
field_name is the only key in kwargs and in ``self.CACHED_FIELDS``
Try to reuse the used ``field_name``s: 'id', 'code', 'url_code'
:return: Valid LangData if (field_name, field_value) pair is for an
**active** language. Otherwise, Dummy LangData which will return
``False`` for all ``self.CACHED_FIELDS``
:rtype: LangData
:raise: UserError if field_name is not in ``self.CACHED_FIELDS``
"""
[[field_name, field_value]] = kwargs.items()
return self._get_active_by(field_name)[field_value]
def _lang_get(self, code: str):
""" Return the language using this code if it is active """
return self.browse(self._get_data(code=code).id)
def _get_code(self, code: str) -> str | Literal[False]:
""" Return the given language code if active, else return ``False`` """
return self._get_data(code=code).code
@api.model
@api.readonly
def get_installed(self) -> list[tuple[str, str]]:
""" Return installed languages' (code, name) pairs sorted by name. """
return [(code, data.name) for code, data in self._get_active_by('code').items()]
@tools.ormcache('field')
def _get_active_by(self, field: str) -> LangDataDict:
""" Return a LangDataDict mapping active languages' **unique**
**required** ``self.CACHED_FIELDS`` values to their LangData.
Its items are ordered by languages' names
Try to reuse the used ``field``s: 'id', 'code', 'url_code'
"""
if field not in self.CACHED_FIELDS:
raise UserError(_('Field "%s" is not cached', field))
if field == 'code':
langs = self.sudo().with_context(active_test=True).search_fetch([], self.CACHED_FIELDS, order='name')
return LangDataDict({
lang.code: LangData({f: lang[f] for f in self.CACHED_FIELDS})
for lang in langs
})
return LangDataDict({data[field]: data for data in self._get_active_by('code').values()})
# ------------------------------------------------------------
def toggle_active(self):
super().toggle_active()
# Automatically load translation
active_lang = [lang.code for lang in self.filtered(lambda l: l.active)]
if active_lang:
mods = self.env['ir.module.module'].search([('state', '=', 'installed')])
mods._update_translations(active_lang)
@api.model_create_multi
def create(self, vals_list):
self.env.registry.clear_cache()
for vals in vals_list:
if not vals.get('url_code'):
vals['url_code'] = vals.get('iso_code') or vals['code']
return super(Lang, self).create(vals_list)
def write(self, vals):
lang_codes = self.mapped('code')
if 'code' in vals and any(code != vals['code'] for code in lang_codes):
raise UserError(_("Language code cannot be modified."))
if vals.get('active') == False:
if self.env['res.users'].with_context(active_test=True).search_count([('lang', 'in', lang_codes)], limit=1):
raise UserError(_("Cannot deactivate a language that is currently used by users."))
if self.env['res.partner'].with_context(active_test=True).search_count([('lang', 'in', lang_codes)], limit=1):
raise UserError(_("Cannot deactivate a language that is currently used by contacts."))
if self.env['res.users'].with_context(active_test=False).search_count([('lang', 'in', lang_codes)], limit=1):
raise UserError(_("You cannot archive the language in which Odoo was setup as it is used by automated processes."))
# delete linked ir.default specifying default partner's language
self.env['ir.default'].discard_values('res.partner', 'lang', lang_codes)
res = super(Lang, self).write(vals)
if vals.get('active'):
# If we activate a lang, set it's url_code to the shortest version
# if possible
for long_lang in self.filtered(lambda lang: '_' in lang.url_code):
short_code = long_lang.code.split('_')[0]
short_lang = self.with_context(active_test=False).search([
('url_code', '=', short_code),
], limit=1) # url_code is unique
if (
short_lang
and not short_lang.active
# `code` should always be the long format containing `_` but
# there is a plan to change this in the future for `es_419`.
# This `and` is about not failing if it's the case one day.
and short_lang.code != short_code
):
short_lang.url_code = short_lang.code
long_lang.url_code = short_code
self.env.flush_all()
self.env.registry.clear_cache()
return res
@api.ondelete(at_uninstall=True)
def _unlink_except_default_lang(self):
for language in self:
if language.code == 'en_US':
raise UserError(_("Base Language 'en_US' can not be deleted."))
ctx_lang = self._context.get('lang')
if ctx_lang and (language.code == ctx_lang):
raise UserError(_("You cannot delete the language which is the user's preferred language."))
if language.active:
raise UserError(_("You cannot delete the language which is Active!\nPlease de-activate the language first."))
def unlink(self):
self.env.registry.clear_cache()
return super(Lang, self).unlink()
def copy_data(self, default=None):
default = dict(default or {})
vals_list = super().copy_data(default=default)
for record, vals in zip(self, vals_list):
if "name" not in default:
vals["name"] = _("%s (copy)", record.name)
if "code" not in default:
vals["code"] = _("%s (copy)", record.code)
if "url_code" not in default:
vals["url_code"] = _("%s (copy)", record.url_code)
return vals_list
def format(self, percent: str, value, grouping: bool = False) -> str:
""" Format() will return the language-specific output for float values"""
self.ensure_one()
if percent[0] != '%':
raise ValueError(_("format() must be given exactly one %char format specifier"))
formatted = percent % value
# floats and decimal ints need special action!
if grouping:
data = self._get_data(id=self.id)
if not data:
raise UserError(_("The language %s is not installed.", self.name))
lang_grouping, thousands_sep, decimal_point = data.grouping, data.thousands_sep or '', data.decimal_point
eval_lang_grouping = ast.literal_eval(lang_grouping)
if percent[-1] in 'eEfFgG':
parts = formatted.split('.')
parts[0] = intersperse(parts[0], eval_lang_grouping, thousands_sep)[0]
formatted = decimal_point.join(parts)
elif percent[-1] in 'diu':
formatted = intersperse(formatted, eval_lang_grouping, thousands_sep)[0]
return formatted
def action_activate_langs(self):
""" Activate the selected languages """
for lang in self.filtered(lambda l: not l.active):
lang.toggle_active()
message = _("The languages that you selected have been successfully installed. Users can choose their favorite language in their preferences.")
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'target': 'new',
'params': {
'message': message,
'type': 'success',
'sticky': False,
'next': {'type': 'ir.actions.act_window_close'},
}
}
def split(l, counts):
"""
>>> split("hello world", [])
['hello world']
>>> split("hello world", [1])
['h', 'ello world']
>>> split("hello world", [2])
['he', 'llo world']
>>> split("hello world", [2,3])
['he', 'llo', ' world']
>>> split("hello world", [2,3,0])
['he', 'llo', ' wo', 'rld']
>>> split("hello world", [2,-1,3])
['he', 'llo world']
"""
res = []
saved_count = len(l) # count to use when encoutering a zero
for count in counts:
if not l:
break
if count == -1:
break
if count == 0:
while l:
res.append(l[:saved_count])
l = l[saved_count:]
break
res.append(l[:count])
l = l[count:]
saved_count = count
if l:
res.append(l)
return res
intersperse_pat = re.compile('([^0-9]*)([^ ]*)(.*)')
def intersperse(string, counts, separator=''):
"""
See the asserts below for examples.
"""
left, rest, right = intersperse_pat.match(string).groups()
def reverse(s): return s[::-1]
splits = split(reverse(rest), counts)
res = separator.join(reverse(s) for s in reverse(splits))
return left + res + right, len(splits) > 0 and len(splits) -1 or 0