feat!: enhance Language to become more of a Locale (#27178)

This commit is contained in:
Raffael Meyer 2024-09-21 16:02:58 +02:00 committed by GitHub
parent 1feef4890c
commit b91cacdd18
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 224 additions and 51 deletions

View file

@ -10,7 +10,12 @@
"language_code", "language_code",
"language_name", "language_name",
"flag", "flag",
"based_on" "based_on",
"section_break_rtth",
"date_format",
"time_format",
"number_format",
"first_day_of_the_week"
], ],
"fields": [ "fields": [
{ {
@ -46,12 +51,40 @@
"fieldname": "enabled", "fieldname": "enabled",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Enabled" "label": "Enabled"
},
{
"fieldname": "section_break_rtth",
"fieldtype": "Section Break"
},
{
"fieldname": "date_format",
"fieldtype": "Select",
"label": "Date Format",
"options": "\nyyyy-mm-dd\ndd-mm-yyyy\ndd/mm/yyyy\ndd.mm.yyyy\nmm/dd/yyyy\nmm-dd-yyyy"
},
{
"fieldname": "time_format",
"fieldtype": "Select",
"label": "Time Format",
"options": "\nHH:mm:ss\nHH:mm"
},
{
"fieldname": "number_format",
"fieldtype": "Select",
"label": "Number Format",
"options": "\n#,###.##\n#.###,##\n# ###.##\n# ###,##\n#'###.##\n#, ###.##\n#,##,###.##\n#,###.###\n#.###\n#,###"
},
{
"fieldname": "first_day_of_the_week",
"fieldtype": "Select",
"label": "First Day of the Week",
"options": "\nSunday\nMonday\nTuesday\nWednesday\nThursday\nFriday\nSaturday"
} }
], ],
"icon": "fa fa-globe", "icon": "fa fa-globe",
"in_create": 1, "in_create": 1,
"links": [], "links": [],
"modified": "2024-06-06 18:25:01.010821", "modified": "2024-07-21 08:25:25.130166",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Core", "module": "Core",
"name": "Language", "name": "Language",

View file

@ -5,6 +5,7 @@ import re
import frappe import frappe
from frappe import _ from frappe import _
from frappe.defaults import clear_default, set_default
from frappe.model.document import Document from frappe.model.document import Document
@ -18,10 +19,30 @@ class Language(Document):
from frappe.types import DF from frappe.types import DF
based_on: DF.Link | None based_on: DF.Link | None
date_format: DF.Literal[
"", "yyyy-mm-dd", "dd-mm-yyyy", "dd/mm/yyyy", "dd.mm.yyyy", "mm/dd/yyyy", "mm-dd-yyyy"
]
enabled: DF.Check enabled: DF.Check
first_day_of_the_week: DF.Literal[
"", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"
]
flag: DF.Data | None flag: DF.Data | None
language_code: DF.Data language_code: DF.Data
language_name: DF.Data language_name: DF.Data
number_format: DF.Literal[
"",
"#,###.##",
"#.###,##",
"# ###.##",
"# ###,##",
"#'###.##",
"#, ###.##",
"#,##,###.##",
"#,###.###",
"#.###",
"#,###",
]
time_format: DF.Literal["", "HH:mm:ss", "HH:mm"]
# end: auto-generated types # end: auto-generated types
def validate(self): def validate(self):
@ -33,6 +54,22 @@ class Language(Document):
def on_update(self): def on_update(self):
frappe.cache.delete_value("languages_with_name") frappe.cache.delete_value("languages_with_name")
frappe.cache.delete_value("languages") frappe.cache.delete_value("languages")
self.update_user_defaults()
def update_user_defaults(self):
"""Update user defaults for date, time, number format and first day of the week.
When we change any settings of a language, the defaults for all users with that language
should be updated.
"""
users = frappe.get_all("User", filters={"language": self.name}, pluck="name")
for key in ("date_format", "time_format", "number_format", "first_day_of_the_week"):
if self.has_value_changed(key):
for user in users:
if new_value := self.get(key):
set_default(key, new_value, user)
else:
clear_default(key, parent=user)
def validate_with_regex(name, label): def validate_with_regex(name, label):

View file

@ -268,6 +268,18 @@ class User(Document):
if self.time_zone: if self.time_zone:
frappe.defaults.set_default("time_zone", self.time_zone, self.name) frappe.defaults.set_default("time_zone", self.time_zone, self.name)
if self.has_value_changed("language"):
locale_keys = ("date_format", "time_format", "number_format", "first_day_of_the_week")
if self.language:
language = frappe.get_doc("Language", self.language)
for key in locale_keys:
value = language.get(key)
if value:
frappe.defaults.set_default(key, value, self.name)
else:
for key in locale_keys:
frappe.defaults.clear_default(key, parent=self.name)
if self.has_value_changed("enabled"): if self.has_value_changed("enabled"):
frappe.cache.delete_key("users_for_mentions") frappe.cache.delete_key("users_for_mentions")
frappe.cache.delete_key("enabled_users") frappe.cache.delete_key("enabled_users")

View file

@ -168,6 +168,9 @@ def set_default(key, value, parent, parenttype="__default"):
else: else:
_clear_cache(parent) _clear_cache(parent)
if parent:
clear_defaults_cache(parent)
def add_default(key, value, parent, parenttype=None): def add_default(key, value, parent, parenttype=None):
d = frappe.get_doc( d = frappe.get_doc(

52
frappe/locale.py Normal file
View file

@ -0,0 +1,52 @@
import frappe
from frappe.utils.number_format import NumberFormat
def get_number_format(language: str | None = None) -> NumberFormat:
"""Return the number format for the given language.
:param language: The language code to get the value for. Defaults to the current user's language.
:return: The number format. Defaults to "#,###.##" if not found.
"""
number_format = get_locale_value("number_format", language) or "#,###.##"
return NumberFormat.from_string(number_format)
def get_date_format(language: str | None = None) -> str:
"""Return the date format for the given language.
:param language: The language code to get the value for. Defaults to the current user's language.
:return: The date format string. Defaults to "yyyy-mm-dd" if not found.
"""
return get_locale_value("date_format", language) or "yyyy-mm-dd"
def get_time_format(language: str | None = None) -> str:
"""Return the time format for the given language.
:param language: The language code to get the value for. Defaults to the current user's language.
:return: The time format string. Defaults to "HH:mm:ss" if not found.
"""
return get_locale_value("time_format", language) or "HH:mm:ss"
def get_first_day_of_the_week(language: str | None = None) -> str:
"""Return the first day of the week for the given language.
:param language: The language code to get the value for. Defaults to the current user's language.
:return: The first day of the week. Defaults to "Sunday" if not found.
"""
return get_locale_value("first_day_of_the_week", language) or "Sunday"
def get_locale_value(key: str, language: str | None = None) -> str | None:
"""Return the value of the key from the Language record or System Settings.
:param key: The settings key to get the value for.
:param language: The language code to get the value for. Defaults to the current user's language.
"""
lang = language or frappe.local.lang
if lang:
value = frappe.db.get_value("Language", lang, key)
return value or frappe.db.get_default(key)

View file

@ -811,7 +811,7 @@ def get_field_currency(df, doc=None):
def get_field_precision(df, doc=None, currency=None): def get_field_precision(df, doc=None, currency=None):
"""get precision based on DocField options and fieldvalue in doc""" """get precision based on DocField options and fieldvalue in doc"""
from frappe.utils import get_number_format_info from frappe.locale import get_number_format
if df.precision: if df.precision:
precision = cint(df.precision) precision = cint(df.precision)
@ -819,8 +819,8 @@ def get_field_precision(df, doc=None, currency=None):
elif df.fieldtype == "Currency": elif df.fieldtype == "Currency":
precision = cint(frappe.db.get_default("currency_precision")) precision = cint(frappe.db.get_default("currency_precision"))
if not precision: if not precision:
number_format = frappe.db.get_default("number_format") or "#,###.##" number_format = get_number_format()
decimal_str, comma_str, precision = get_number_format_info(number_format) precision = number_format.precision
else: else:
precision = cint(frappe.db.get_default("float_precision")) or 3 precision = cint(frappe.db.get_default("float_precision")) or 3

View file

@ -24,6 +24,9 @@ from dateutil.relativedelta import relativedelta
import frappe import frappe
from frappe.desk.utils import slug from frappe.desk.utils import slug
from frappe.locale import get_date_format, get_first_day_of_the_week, get_number_format, get_time_format
from frappe.utils.deprecations import deprecated
from frappe.utils.number_format import NUMBER_FORMAT_MAP, NumberFormat
DateTimeLikeObject = str | datetime.date | datetime.datetime DateTimeLikeObject = str | datetime.date | datetime.datetime
NumericType = int | float NumericType = int | float
@ -85,10 +88,6 @@ class Weekday(Enum):
Saturday = 6 Saturday = 6
def get_first_day_of_the_week() -> str:
return frappe.get_system_settings("first_day_of_the_week") or "Sunday"
def get_start_of_week_index() -> int: def get_start_of_week_index() -> int:
return Weekday[get_first_day_of_the_week()].value return Weekday[get_first_day_of_the_week()].value
@ -677,9 +676,9 @@ def get_time_str(timedelta_obj: datetime.timedelta | str) -> str:
def get_user_date_format() -> str: def get_user_date_format() -> str:
"""Get the current user date format. The result will be cached.""" """Get the current user date format. The result will be cached."""
if getattr(frappe.local, "user_date_format", None) is None: if getattr(frappe.local, "user_date_format", None) is None:
frappe.local.user_date_format = frappe.db.get_default("date_format") frappe.local.user_date_format = get_date_format()
return frappe.local.user_date_format or "yyyy-mm-dd" return frappe.local.user_date_format
get_user_format = get_user_date_format # for backwards compatibility get_user_format = get_user_date_format # for backwards compatibility
@ -688,9 +687,9 @@ get_user_format = get_user_date_format # for backwards compatibility
def get_user_time_format() -> str: def get_user_time_format() -> str:
"""Get the current user time format. The result will be cached.""" """Get the current user time format. The result will be cached."""
if getattr(frappe.local, "user_time_format", None) is None: if getattr(frappe.local, "user_time_format", None) is None:
frappe.local.user_time_format = frappe.db.get_default("time_format") frappe.local.user_time_format = get_time_format()
return frappe.local.user_time_format or "HH:mm:ss" return frappe.local.user_time_format
def format_date(string_date=None, format_string: str | None = None, parse_day_first: bool = False) -> str: def format_date(string_date=None, format_string: str | None = None, parse_day_first: bool = False) -> str:
@ -1347,14 +1346,13 @@ def fmt_money(
format: str | None = None, format: str | None = None,
) -> str: ) -> str:
"""Convert to string with commas for thousands, millions etc.""" """Convert to string with commas for thousands, millions etc."""
number_format = format or frappe.db.get_default("number_format") or "#,###.##" number_format = NumberFormat.from_string(format) if format else get_number_format()
if precision is None: if precision is None:
precision = cint(frappe.db.get_default("currency_precision")) or None precision = cint(frappe.db.get_default("currency_precision")) or None
decimal_str, comma_str, number_format_precision = get_number_format_info(number_format)
if precision is None: if precision is None:
precision = number_format_precision precision = number_format.precision
# 40,000 -> 40,000.00 # 40,000 -> 40,000.00
# 40,000.00000 -> 40,000.00 # 40,000.00000 -> 40,000.00
@ -1366,7 +1364,7 @@ def fmt_money(
if amount is None: if amount is None:
amount = 0 amount = 0
if decimal_str: if number_format.decimal_separator:
decimals_after = str(round(amount % 1, precision)) decimals_after = str(round(amount % 1, precision))
parts = decimals_after.split(".") parts = decimals_after.split(".")
parts = parts[1] if len(parts) > 1 else parts[0] parts = parts[1] if len(parts) > 1 else parts[0]
@ -1377,7 +1375,7 @@ def fmt_money(
fraction = frappe.db.get_value("Currency", currency, "fraction_units", cache=True) or 100 fraction = frappe.db.get_value("Currency", currency, "fraction_units", cache=True) or 100
precision = len(cstr(fraction)) - 1 precision = len(cstr(fraction)) - 1
else: else:
precision = number_format_precision precision = number_format.precision
elif len(decimals) < precision: elif len(decimals) < precision:
precision = len(decimals) precision = len(decimals)
@ -1399,7 +1397,7 @@ def fmt_money(
parts.append(amount[-3:]) parts.append(amount[-3:])
amount = amount[:-3] amount = amount[:-3]
val = number_format == "#,##,###.##" and 2 or 3 val = 2 if number_format.string == "#,##,###.##" else 3
while len(amount) > val: while len(amount) > val:
parts.append(amount[-val:]) parts.append(amount[-val:])
@ -1409,7 +1407,9 @@ def fmt_money(
parts.reverse() parts.reverse()
amount = comma_str.join(parts) + ((precision and decimal_str) and (decimal_str + decimals) or "") amount = number_format.thousands_separator.join(parts) + (
(precision and number_format.decimal_separator) and (number_format.decimal_separator + decimals) or ""
)
if amount != "0": if amount != "0":
amount = minus + amount amount = minus + amount
@ -1425,29 +1425,21 @@ def fmt_money(
return amount return amount
number_format_info = { # keep for backwards compatibility
"#,###.##": (".", ",", 2), number_format_info = NUMBER_FORMAT_MAP
"#.###,##": (",", ".", 2),
"# ###.##": (".", " ", 2),
"# ###,##": (",", " ", 2),
"#'###.##": (".", "'", 2),
"#, ###.##": (".", ", ", 2),
"#,##,###.##": (".", ",", 2),
"#,###.###": (".", ",", 3),
"#.###": ("", ".", 0),
"#,###": ("", ",", 0),
"#.########": (".", "", 8),
}
@deprecated
def get_number_format_info(format: str) -> tuple[str, str, int]: def get_number_format_info(format: str) -> tuple[str, str, int]:
"""Return the decimal separator, thousands separator and precision for the given number `format` string. """DEPRECATED: use `NumberFormat.from_string()` from `frappe.utils.number_format` instead.
e.g. get_number_format_info('1,00,000.50') -> ('.', ',', 2) Return the decimal separator, thousands separator and precision for the given number `format` string.
e.g. get_number_format_info('#,##,###.##') -> ('.', ',', 2)
Will return ('.', ',', 2) for format strings which can't be guessed. Will return ('.', ',', 2) for format strings which can't be guessed.
""" """
return number_format_info.get(format) or (".", ",", 2) return NUMBER_FORMAT_MAP.get(format) or (".", ",", 2)
# #
@ -1481,13 +1473,13 @@ def money_in_words(
"Cent" "Cent"
) )
number_format = ( currency_format_str = frappe.db.get_value("Currency", main_currency, "number_format", cache=True)
frappe.db.get_value("Currency", main_currency, "number_format", cache=True) if currency_format_str:
or frappe.db.get_default("number_format") number_format = NumberFormat.from_string(currency_format_str)
or "#,###.##" else:
) number_format = get_number_format()
fraction_length = get_number_format_info(number_format)[2] fraction_length = number_format.precision
n = f"%.{fraction_length}f" % number n = f"%.{fraction_length}f" % number
@ -1499,7 +1491,7 @@ def money_in_words(
fraction += zeros fraction += zeros
in_million = True in_million = True
if number_format == "#,##,###.##": if number_format.string == "#,##,###.##":
in_million = False in_million = False
# 0.00 # 0.00

View file

@ -5,6 +5,7 @@ import datetime
import frappe import frappe
import frappe.defaults import frappe.defaults
from frappe.locale import get_date_format
from frappe.utils import add_to_date, get_datetime, getdate from frappe.utils import add_to_date, get_datetime, getdate
from frappe.utils.data import ( from frappe.utils.data import (
get_first_day, get_first_day,
@ -75,7 +76,7 @@ def parse_date(date):
def get_user_date_format(): def get_user_date_format():
if getattr(frappe.local, "user_date_format", None) is None: if getattr(frappe.local, "user_date_format", None) is None:
frappe.local.user_date_format = frappe.defaults.get_global_default("date_format") or "yyyy-mm-dd" frappe.local.user_date_format = get_date_format()
return frappe.local.user_date_format return frappe.local.user_date_format

View file

@ -0,0 +1,36 @@
from dataclasses import dataclass
NUMBER_FORMAT_MAP = {
"#,###.##": (".", ",", 2),
"#.###,##": (",", ".", 2),
"# ###.##": (".", " ", 2),
"# ###,##": (",", " ", 2),
"#'###.##": (".", "'", 2),
"#, ###.##": (".", ", ", 2),
"#,##,###.##": (".", ",", 2),
"#,###.###": (".", ",", 3),
"#.###": ("", ".", 0),
"#,###": ("", ",", 0),
"#.########": (".", "", 8),
}
@dataclass
class NumberFormat:
precision: int
decimal_separator: str
thousands_separator: str
string: str
@classmethod
def from_string(cls, number_format: str) -> "NumberFormat":
decimal_separator, thousands_separator, precision = NUMBER_FORMAT_MAP[number_format]
return NumberFormat(
precision=precision,
decimal_separator=decimal_separator,
thousands_separator=thousands_separator,
string=number_format,
)
def __str__(self) -> str:
return self.string

View file

@ -21,11 +21,13 @@ from frappe import _
from frappe.core.utils import html2text from frappe.core.utils import html2text
from frappe.frappeclient import FrappeClient from frappe.frappeclient import FrappeClient
from frappe.handler import execute_cmd from frappe.handler import execute_cmd
from frappe.locale import get_date_format, get_number_format, get_time_format
from frappe.model.delete_doc import delete_doc from frappe.model.delete_doc import delete_doc
from frappe.model.mapper import get_mapped_doc from frappe.model.mapper import get_mapped_doc
from frappe.model.rename_doc import rename_doc from frappe.model.rename_doc import rename_doc
from frappe.modules import scrub from frappe.modules import scrub
from frappe.utils.background_jobs import enqueue, get_jobs from frappe.utils.background_jobs import enqueue, get_jobs
from frappe.utils.number_format import NumberFormat
from frappe.website.utils import get_next_link, get_toc from frappe.website.utils import get_next_link, get_toc
from frappe.www.printview import get_visible_columns from frappe.www.printview import get_visible_columns
@ -164,11 +166,13 @@ def get_safe_globals():
datautils = frappe._dict() datautils = frappe._dict()
if frappe.db: if frappe.db:
date_format = frappe.db.get_default("date_format") or "yyyy-mm-dd" date_format = get_date_format()
time_format = frappe.db.get_default("time_format") or "HH:mm:ss" time_format = get_time_format()
number_format = get_number_format()
else: else:
date_format = "yyyy-mm-dd" date_format = "yyyy-mm-dd"
time_format = "HH:mm:ss" time_format = "HH:mm:ss"
number_format = NumberFormat.from_string("#,###.##")
add_data_utils(datautils) add_data_utils(datautils)
@ -194,6 +198,7 @@ def get_safe_globals():
format_value=frappe.format_value, format_value=frappe.format_value,
date_format=date_format, date_format=date_format,
time_format=time_format, time_format=time_format,
number_format=number_format,
format_date=frappe.utils.data.global_date_format, format_date=frappe.utils.data.global_date_format,
form_dict=form_dict, form_dict=form_dict,
bold=frappe.bold, bold=frappe.bold,

View file

@ -164,6 +164,8 @@ def get_home_page_via_hooks():
def get_boot_data(): def get_boot_data():
from frappe.locale import get_date_format, get_first_day_of_the_week, get_number_format, get_time_format
return { return {
"lang": frappe.local.lang or "en", "lang": frappe.local.lang or "en",
"apps_data": { "apps_data": {
@ -173,10 +175,10 @@ def get_boot_data():
}, },
"sysdefaults": { "sysdefaults": {
"float_precision": cint(frappe.get_system_settings("float_precision")) or 3, "float_precision": cint(frappe.get_system_settings("float_precision")) or 3,
"date_format": frappe.get_system_settings("date_format") or "yyyy-mm-dd", "date_format": get_date_format(),
"time_format": frappe.get_system_settings("time_format") or "HH:mm:ss", "time_format": get_time_format(),
"first_day_of_the_week": frappe.get_system_settings("first_day_of_the_week") or "Sunday", "first_day_of_the_week": get_first_day_of_the_week(),
"number_format": frappe.get_system_settings("number_format") or "#,###.##", "number_format": get_number_format().string,
}, },
"time_zone": { "time_zone": {
"system": get_system_timezone(), "system": get_system_timezone(),