diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index 3d9a7ba938..5eb3e55c4e 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -1085,6 +1085,12 @@ def validate_series(dt, autoname=None, name=None): df.unique = 1 break + if autoname and autoname.startswith("format:"): + from frappe.model.naming import BRACED_PARAMS_HASH_PATTERN + + if len(BRACED_PARAMS_HASH_PATTERN.findall(autoname)) > 1: + frappe.throw(_("Only one set of {#} pattern is allowed in the format string")) + if ( autoname and (not autoname.startswith("field:")) diff --git a/frappe/model/naming.py b/frappe/model/naming.py index 966ebff671..595cb28ef0 100644 --- a/frappe/model/naming.py +++ b/frappe/model/naming.py @@ -23,7 +23,8 @@ if TYPE_CHECKING: NAMING_SERIES_PATTERN = re.compile(r"^[\w\- \/.#{}]+$", re.UNICODE) -BRACED_PARAMS_PATTERN = re.compile(r"(\{[\w | #]+\})") +BRACED_PARAMS_WORD_PATTERN = re.compile(r"(\{[\w]+\})") +BRACED_PARAMS_HASH_PATTERN = re.compile(r"(\{[#]+\})") # Types that can be using in naming series fields @@ -314,6 +315,7 @@ def parse_naming_series( doctype=None, doc: Optional["Document"] = None, number_generator: Callable[[str, int], str] | None = None, + key: str | None = None, ) -> str: """Parse the naming series and get next name. @@ -341,7 +343,10 @@ def parse_naming_series( if e.startswith("#"): if not series_set: digits = len(e) - part = number_generator(name, digits) + if key: + part = number_generator(key, digits) + else: + part = number_generator(name, digits) series_set = True elif e == "YY": part = today.strftime("%y") @@ -575,11 +580,19 @@ def _format_autoname(autoname: str, doc): first_colon_index = autoname.find(":") autoname_value = autoname[first_colon_index + 1 :] - def get_param_value_for_match(match): + def get_param_value_for_word_match(match): param = match.group() return parse_naming_series([param[1:-1]], doc=doc) - # Replace braced params with their parsed value - name = BRACED_PARAMS_PATTERN.sub(get_param_value_for_match, autoname_value) + def get_param_value_for_hash_match(patterned_string: str): + def get_param_value(match): + param = match.group() + key = patterned_string[: patterned_string.find(param)] - return name + return parse_naming_series([param[1:-1]], doc=doc, key=key) + + return get_param_value + + # Replace braced params with their parsed value + autoname_value = BRACED_PARAMS_WORD_PATTERN.sub(get_param_value_for_word_match, autoname_value) + return BRACED_PARAMS_HASH_PATTERN.sub(get_param_value_for_hash_match(autoname_value), autoname_value) diff --git a/frappe/patches.txt b/frappe/patches.txt index 4d51a2ce96..94c216bb2e 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -242,3 +242,4 @@ execute:frappe.db.set_single_value("Workspace Settings", "workspace_setup_comple frappe.patches.v16_0.add_app_launcher_in_navbar_settings frappe.desk.doctype.workspace.patches.update_app frappe.patches.v16_0.move_role_desk_settings_to_user +frappe.patches.v16_0.update_expression_series diff --git a/frappe/patches/v16_0/update_expression_series.py b/frappe/patches/v16_0/update_expression_series.py new file mode 100644 index 0000000000..0b4d81235e --- /dev/null +++ b/frappe/patches/v16_0/update_expression_series.py @@ -0,0 +1,59 @@ +import frappe +from frappe.model.naming import ( + BRACED_PARAMS_WORD_PATTERN, + determine_consecutive_week_number, + has_custom_parser, +) +from frappe.query_builder import DocType + + +def execute(): + Series = DocType("Series") + doctypes = frappe.get_all("DocType", filters={"naming_rule": "expression"}, fields=["name", "autoname"]) + uniq_exprs = set() + + def get_param_value_for_word_match(doc): + def get_param_value(match): + e = match.group()[1:-1] + creation = doc.creation + _sentinel = object() + part = "" + if e == "YY": + part = creation.strftime("%y") + elif e == "MM": + part = creation.strftime("%m") + elif e == "DD": + part = creation.strftime("%d") + elif e == "YYYY": + part = creation.strftime("%Y") + elif e == "WW": + part = determine_consecutive_week_number(creation) + elif e == "timestamp": + part = str(creation) + elif doc and (e.startswith("{") or doc.get(e, _sentinel) is not _sentinel): + e = e.replace("{", "").replace("}", "") + part = doc.get(e) + elif method := has_custom_parser(e): + part = frappe.get_attr(method[0])(doc, e) + else: + part = e + return part + + return get_param_value + + for doctype in doctypes: + if "#" in doctype.autoname: + docs = frappe.get_all(doctype.name) + if docs: + for doc in docs: + _doc = frappe.get_doc(doctype.name, doc.name) + expr = doctype.autoname[7 : doctype.autoname.find("{#")] + key = BRACED_PARAMS_WORD_PATTERN.sub(get_param_value_for_word_match(_doc), expr) + uniq_exprs.add(key) + + current = (frappe.qb.from_(Series).select("*").where(Series.name == "")).run(as_dict=True) + if current: + current = current[0].get("current") + + for uniq_expr in uniq_exprs: + (frappe.qb.into(Series).columns("name", "current").insert(uniq_expr, current + 1)).run() diff --git a/frappe/tests/test_naming.py b/frappe/tests/test_naming.py index d52f48c3ec..21c24e34d0 100644 --- a/frappe/tests/test_naming.py +++ b/frappe/tests/test_naming.py @@ -103,7 +103,8 @@ class TestNaming(IntegrationTestCase): doc.some_fieldname = description doc.insert() - series = getseries("", 2) + series = getseries(f"TODO-{now_datetime().strftime('%m')}-{description}-", 2) + series = int(series) - 1 self.assertEqual(doc.name, f"TODO-{now_datetime().strftime('%m')}-{description}-{series:02}") @@ -117,7 +118,7 @@ class TestNaming(IntegrationTestCase): doc.field = field doc.insert() - series = getseries("", 2) + series = getseries(f"TODO-{field}-", 2) series = int(series) - 1 self.assertEqual(doc.name, f"TODO-{field}-{series:02}") @@ -138,15 +139,13 @@ class TestNaming(IntegrationTestCase): todo.description = description todo.insert() - series = getseries("", 2) - + week = determine_consecutive_week_number(now_datetime()) + series = getseries(f"TODO-{week}-", 2) series = str(int(series) - 1) if len(series) < 2: series = "0" + series - week = determine_consecutive_week_number(now_datetime()) - self.assertEqual(todo.name, f"TODO-{week}-{series}") def test_revert_series(self):