diff --git a/frappe/core/doctype/language/language.json b/frappe/core/doctype/language/language.json index acce2728b1..ab74a7d4f9 100644 --- a/frappe/core/doctype/language/language.json +++ b/frappe/core/doctype/language/language.json @@ -10,7 +10,12 @@ "language_code", "language_name", "flag", - "based_on" + "based_on", + "section_break_rtth", + "date_format", + "time_format", + "number_format", + "first_day_of_the_week" ], "fields": [ { @@ -46,12 +51,40 @@ "fieldname": "enabled", "fieldtype": "Check", "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", "in_create": 1, "links": [], - "modified": "2024-06-06 18:25:01.010821", + "modified": "2024-07-21 08:25:25.130166", "modified_by": "Administrator", "module": "Core", "name": "Language", diff --git a/frappe/core/doctype/language/language.py b/frappe/core/doctype/language/language.py index 57a2ccf690..049349cd3b 100644 --- a/frappe/core/doctype/language/language.py +++ b/frappe/core/doctype/language/language.py @@ -5,6 +5,7 @@ import re import frappe from frappe import _ +from frappe.defaults import clear_default, set_default from frappe.model.document import Document @@ -18,10 +19,30 @@ class Language(Document): from frappe.types import DF 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 + first_day_of_the_week: DF.Literal[ + "", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" + ] flag: DF.Data | None language_code: DF.Data language_name: DF.Data + number_format: DF.Literal[ + "", + "#,###.##", + "#.###,##", + "# ###.##", + "# ###,##", + "#'###.##", + "#, ###.##", + "#,##,###.##", + "#,###.###", + "#.###", + "#,###", + ] + time_format: DF.Literal["", "HH:mm:ss", "HH:mm"] # end: auto-generated types def validate(self): @@ -33,6 +54,22 @@ class Language(Document): def on_update(self): frappe.cache.delete_value("languages_with_name") 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): diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 1ef8a824ea..a1f0b05ed2 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -268,6 +268,18 @@ class User(Document): if self.time_zone: 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"): frappe.cache.delete_key("users_for_mentions") frappe.cache.delete_key("enabled_users") diff --git a/frappe/defaults.py b/frappe/defaults.py index af95c273a1..44f292645f 100644 --- a/frappe/defaults.py +++ b/frappe/defaults.py @@ -168,6 +168,9 @@ def set_default(key, value, parent, parenttype="__default"): else: _clear_cache(parent) + if parent: + clear_defaults_cache(parent) + def add_default(key, value, parent, parenttype=None): d = frappe.get_doc( diff --git a/frappe/locale.py b/frappe/locale.py new file mode 100644 index 0000000000..9e4c027531 --- /dev/null +++ b/frappe/locale.py @@ -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) diff --git a/frappe/model/meta.py b/frappe/model/meta.py index 6c6a66d4a7..1358700e6e 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -811,7 +811,7 @@ def get_field_currency(df, doc=None): def get_field_precision(df, doc=None, currency=None): """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: precision = cint(df.precision) @@ -819,8 +819,8 @@ def get_field_precision(df, doc=None, currency=None): elif df.fieldtype == "Currency": precision = cint(frappe.db.get_default("currency_precision")) if not precision: - number_format = frappe.db.get_default("number_format") or "#,###.##" - decimal_str, comma_str, precision = get_number_format_info(number_format) + number_format = get_number_format() + precision = number_format.precision else: precision = cint(frappe.db.get_default("float_precision")) or 3 diff --git a/frappe/utils/data.py b/frappe/utils/data.py index 4841281bc6..5662d180f8 100644 --- a/frappe/utils/data.py +++ b/frappe/utils/data.py @@ -24,6 +24,9 @@ from dateutil.relativedelta import relativedelta import frappe 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 NumericType = int | float @@ -85,10 +88,6 @@ class Weekday(Enum): 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: 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: """Get the current user date format. The result will be cached.""" 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 @@ -688,9 +687,9 @@ get_user_format = get_user_date_format # for backwards compatibility def get_user_time_format() -> str: """Get the current user time format. The result will be cached.""" 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: @@ -1347,14 +1346,13 @@ def fmt_money( format: str | None = None, ) -> str: """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: 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: - precision = number_format_precision + precision = number_format.precision # 40,000 -> 40,000.00 # 40,000.00000 -> 40,000.00 @@ -1366,7 +1364,7 @@ def fmt_money( if amount is None: amount = 0 - if decimal_str: + if number_format.decimal_separator: decimals_after = str(round(amount % 1, precision)) parts = decimals_after.split(".") 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 precision = len(cstr(fraction)) - 1 else: - precision = number_format_precision + precision = number_format.precision elif len(decimals) < precision: precision = len(decimals) @@ -1399,7 +1397,7 @@ def fmt_money( parts.append(amount[-3:]) amount = amount[:-3] - val = number_format == "#,##,###.##" and 2 or 3 + val = 2 if number_format.string == "#,##,###.##" else 3 while len(amount) > val: parts.append(amount[-val:]) @@ -1409,7 +1407,9 @@ def fmt_money( 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": amount = minus + amount @@ -1425,29 +1425,21 @@ def fmt_money( return amount -number_format_info = { - "#,###.##": (".", ",", 2), - "#.###,##": (",", ".", 2), - "# ###.##": (".", " ", 2), - "# ###,##": (",", " ", 2), - "#'###.##": (".", "'", 2), - "#, ###.##": (".", ", ", 2), - "#,##,###.##": (".", ",", 2), - "#,###.###": (".", ",", 3), - "#.###": ("", ".", 0), - "#,###": ("", ",", 0), - "#.########": (".", "", 8), -} +# keep for backwards compatibility +number_format_info = NUMBER_FORMAT_MAP +@deprecated 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. """ - 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" ) - number_format = ( - frappe.db.get_value("Currency", main_currency, "number_format", cache=True) - or frappe.db.get_default("number_format") - or "#,###.##" - ) + currency_format_str = frappe.db.get_value("Currency", main_currency, "number_format", cache=True) + if currency_format_str: + number_format = NumberFormat.from_string(currency_format_str) + 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 @@ -1499,7 +1491,7 @@ def money_in_words( fraction += zeros in_million = True - if number_format == "#,##,###.##": + if number_format.string == "#,##,###.##": in_million = False # 0.00 diff --git a/frappe/utils/dateutils.py b/frappe/utils/dateutils.py index 26607aa861..4d6e4efe0d 100644 --- a/frappe/utils/dateutils.py +++ b/frappe/utils/dateutils.py @@ -5,6 +5,7 @@ import datetime import frappe import frappe.defaults +from frappe.locale import get_date_format from frappe.utils import add_to_date, get_datetime, getdate from frappe.utils.data import ( get_first_day, @@ -75,7 +76,7 @@ def parse_date(date): def get_user_date_format(): 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 diff --git a/frappe/utils/number_format.py b/frappe/utils/number_format.py new file mode 100644 index 0000000000..b19bfd8dee --- /dev/null +++ b/frappe/utils/number_format.py @@ -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 diff --git a/frappe/utils/safe_exec.py b/frappe/utils/safe_exec.py index 1d5351729a..dea987e7bf 100644 --- a/frappe/utils/safe_exec.py +++ b/frappe/utils/safe_exec.py @@ -21,11 +21,13 @@ from frappe import _ from frappe.core.utils import html2text from frappe.frappeclient import FrappeClient 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.mapper import get_mapped_doc from frappe.model.rename_doc import rename_doc from frappe.modules import scrub 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.www.printview import get_visible_columns @@ -164,11 +166,13 @@ def get_safe_globals(): datautils = frappe._dict() if frappe.db: - date_format = frappe.db.get_default("date_format") or "yyyy-mm-dd" - time_format = frappe.db.get_default("time_format") or "HH:mm:ss" + date_format = get_date_format() + time_format = get_time_format() + number_format = get_number_format() else: date_format = "yyyy-mm-dd" time_format = "HH:mm:ss" + number_format = NumberFormat.from_string("#,###.##") add_data_utils(datautils) @@ -194,6 +198,7 @@ def get_safe_globals(): format_value=frappe.format_value, date_format=date_format, time_format=time_format, + number_format=number_format, format_date=frappe.utils.data.global_date_format, form_dict=form_dict, bold=frappe.bold, diff --git a/frappe/website/utils.py b/frappe/website/utils.py index 678ff57dda..74b5fcfead 100644 --- a/frappe/website/utils.py +++ b/frappe/website/utils.py @@ -164,6 +164,8 @@ def get_home_page_via_hooks(): def get_boot_data(): + from frappe.locale import get_date_format, get_first_day_of_the_week, get_number_format, get_time_format + return { "lang": frappe.local.lang or "en", "apps_data": { @@ -173,10 +175,10 @@ def get_boot_data(): }, "sysdefaults": { "float_precision": cint(frappe.get_system_settings("float_precision")) or 3, - "date_format": frappe.get_system_settings("date_format") or "yyyy-mm-dd", - "time_format": frappe.get_system_settings("time_format") or "HH:mm:ss", - "first_day_of_the_week": frappe.get_system_settings("first_day_of_the_week") or "Sunday", - "number_format": frappe.get_system_settings("number_format") or "#,###.##", + "date_format": get_date_format(), + "time_format": get_time_format(), + "first_day_of_the_week": get_first_day_of_the_week(), + "number_format": get_number_format().string, }, "time_zone": { "system": get_system_timezone(),