From c1329b658add522042422a772925fd347efe30d4 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Tue, 12 May 2026 17:40:38 +0300 Subject: [PATCH 1/2] feat: add custom button to create renewal tasks in License Register doctype (cherry picked from commit f7e0da4633706510188567422390dc6f93de53c6) --- .../license_register/license_register.js | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/av_tools/compliance/doctype/license_register/license_register.js b/av_tools/compliance/doctype/license_register/license_register.js index fc58f2f..efbe365 100644 --- a/av_tools/compliance/doctype/license_register/license_register.js +++ b/av_tools/compliance/doctype/license_register/license_register.js @@ -1,8 +1,16 @@ // Copyright (c) 2026, Aakvatech and contributors // For license information, please see license.txt -// frappe.ui.form.on("License Register", { -// refresh(frm) { - -// }, -// }); +frappe.ui.form.on("License Register", { + refresh(frm) { + if (!frm.is_new()) { + frm.add_custom_button(__("Create Renewal Task"), () => { + frappe.new_doc("Task", { + subject: `Renew License: ${frm.doc.license_name || frm.doc.name}`, + exp_end_date: frm.doc.expiry_date, + description: `Renewal task for License Register ${frm.doc.name}`, + }); + }); + } + } +}); From c4d2ccb066f21f794683c2d96c86b7141042167e Mon Sep 17 00:00:00 2001 From: MariamMabele Date: Wed, 13 May 2026 20:53:43 +0300 Subject: [PATCH 2/2] feat: add multi-term item search override in av_tools (cherry picked from commit f2653a6fd2d41777b93320ec669df7fdd5bf90fc) --- .../special_closing_balance.js | 2 +- av_tools/av_tools_hooks/item_search.py | 188 ++++++++++++++++++ av_tools/av_tools_hooks/test_item_search.py | 71 +++++++ av_tools/hooks.py | 2 + 4 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 av_tools/av_tools_hooks/item_search.py create mode 100644 av_tools/av_tools_hooks/test_item_search.py diff --git a/av_tools/av_tools/doctype/special_closing_balance/special_closing_balance.js b/av_tools/av_tools/doctype/special_closing_balance/special_closing_balance.js index fe9c79b..f8204c2 100644 --- a/av_tools/av_tools/doctype/special_closing_balance/special_closing_balance.js +++ b/av_tools/av_tools/doctype/special_closing_balance/special_closing_balance.js @@ -6,7 +6,7 @@ frappe.ui.form.on('Special Closing Balance', { frm.set_query("item_code", "closing_balance_details", function(doc, cdt, cdn) { return { - query: "erpnext.controllers.queries.item_query", + query: "av_tools.av_tools_hooks.item_search.item_query", filters:{ "is_stock_item": 1 } diff --git a/av_tools/av_tools_hooks/item_search.py b/av_tools/av_tools_hooks/item_search.py new file mode 100644 index 0000000..4675f63 --- /dev/null +++ b/av_tools/av_tools_hooks/item_search.py @@ -0,0 +1,188 @@ +import json +import re + +import frappe +from frappe import scrub +from frappe.desk.reportview import get_filters_cond, get_match_cond +from frappe.desk.search import search_link as original_search_link +from frappe.desk.search import search_widget as original_search_widget +from frappe.utils import nowdate + + +ERP_ITEM_QUERY = "erpnext.controllers.queries.item_query" +AV_TOOLS_ITEM_QUERY = "av_tools.av_tools_hooks.item_search.item_query" + + +def split_search_terms(txt: str | None) -> list[str]: + if not txt or not isinstance(txt, str): + return [] + + return [part for part in re.split(r"\s+", txt.strip()) if part] + + +def route_item_query(doctype, query): + if doctype == "Item" and not query: + return AV_TOOLS_ITEM_QUERY + + if query == ERP_ITEM_QUERY: + return AV_TOOLS_ITEM_QUERY + + return query + + +@frappe.whitelist() +def search_link( + doctype, + txt, + query=None, + filters=None, + page_length=10, + searchfield=None, + reference_doctype=None, + ignore_user_permissions=False, +): + return original_search_link( + doctype=doctype, + txt=txt, + query=route_item_query(doctype, query), + filters=filters, + page_length=page_length, + searchfield=searchfield, + reference_doctype=reference_doctype, + ignore_user_permissions=ignore_user_permissions, + ) + + +@frappe.whitelist() +def search_widget( + doctype, + txt, + query=None, + searchfield=None, + start=0, + page_length=10, + filters=None, + filter_fields=None, + as_dict=False, + reference_doctype=None, + ignore_user_permissions=False, +): + return original_search_widget( + doctype=doctype, + txt=txt, + query=route_item_query(doctype, query), + searchfield=searchfield, + start=start, + page_length=page_length, + filters=filters, + filter_fields=filter_fields, + as_dict=as_dict, + reference_doctype=reference_doctype, + ignore_user_permissions=ignore_user_permissions, + ) + + +@frappe.whitelist() +@frappe.validate_and_sanitize_search_inputs +def item_query(doctype, txt, searchfield, start, page_len, filters, as_dict=False): + doctype = "Item" + conditions = [] + search_terms = split_search_terms(txt) + search_text = "%".join(search_terms) if search_terms else "" + + if isinstance(filters, str): + filters = json.loads(filters) + + meta = frappe.get_meta(doctype, cached=True) + searchfields = meta.get_search_fields() + + columns = "" + extra_searchfields = [field for field in searchfields if field not in ["name", "description"]] + + if extra_searchfields: + columns += ", " + ", ".join(extra_searchfields) + + if "description" in searchfields: + columns += """, if(length(tabItem.description) > 40, \ + concat(substr(tabItem.description, 1, 40), "..."), description) as description""" + + searchfields = searchfields + [ + field + for field in [searchfield or "name", "item_code", "item_group", "item_name"] + if field not in searchfields + ] + + def build_search_condition(field): + if len(search_terms) <= 1: + return f"{field} like %(txt)s" + + return "(" + " and ".join([f"{field} like %(txt_{i})s" for i in range(len(search_terms))]) + ")" + + searchfields = " or ".join([build_search_condition(field) for field in searchfields]) + + if filters and isinstance(filters, dict): + if filters.get("customer") or filters.get("supplier"): + party = filters.get("customer") or filters.get("supplier") + item_rules_list = frappe.get_all( + "Party Specific Item", + filters={"party": party}, + fields=["restrict_based_on", "based_on_value"], + ) + + filters_dict = {} + for rule in item_rules_list: + if rule["restrict_based_on"] == "Item": + rule["restrict_based_on"] = "name" + filters_dict[rule.restrict_based_on] = [] + + for rule in item_rules_list: + filters_dict[rule.restrict_based_on].append(rule.based_on_value) + + for filter in filters_dict: + filters[scrub(filter)] = ["in", filters_dict[filter]] + + if filters.get("customer"): + del filters["customer"] + else: + del filters["supplier"] + else: + filters.pop("customer", None) + filters.pop("supplier", None) + + description_cond = "" + if frappe.db.count(doctype, cache=True) < 50000: + description_cond = f"or {build_search_condition('tabItem.description')}" + + return frappe.db.sql( + """select + tabItem.name {columns} + from tabItem + where tabItem.docstatus < 2 + and tabItem.disabled=0 + and tabItem.has_variants=0 + and (tabItem.end_of_life > %(today)s or ifnull(tabItem.end_of_life, '0000-00-00')='0000-00-00') + and ({scond} or tabItem.item_code IN (select parent from `tabItem Barcode` where barcode LIKE %(txt)s) + {description_cond}) + {fcond} {mcond} + order by + if(locate(%(_txt)s, name), locate(%(_txt)s, name), 99999), + if(locate(%(_txt)s, item_name), locate(%(_txt)s, item_name), 99999), + idx desc, + name, item_name + limit %(start)s, %(page_len)s """.format( + columns=columns, + scond=searchfields, + fcond=get_filters_cond(doctype, filters, conditions).replace("%", "%%"), + mcond=get_match_cond(doctype).replace("%", "%%"), + description_cond=description_cond, + ), + { + "today": nowdate(), + "txt": f"%{search_text}%", + "_txt": (search_terms[0] if search_terms else "").replace("%", ""), + "start": start, + "page_len": page_len, + **{f"txt_{i}": f"%{term}%" for i, term in enumerate(search_terms)}, + }, + as_dict=as_dict, + ) diff --git a/av_tools/av_tools_hooks/test_item_search.py b/av_tools/av_tools_hooks/test_item_search.py new file mode 100644 index 0000000..faa9a5e --- /dev/null +++ b/av_tools/av_tools_hooks/test_item_search.py @@ -0,0 +1,71 @@ +from unittest import TestCase +from unittest.mock import patch + +from av_tools.av_tools_hooks import item_search + + +class TestItemSearch(TestCase): + def test_split_search_terms(self): + self.assertEqual(item_search.split_search_terms("blue chair"), ["blue", "chair"]) + self.assertEqual(item_search.split_search_terms(" blue chair "), ["blue", "chair"]) + self.assertEqual(item_search.split_search_terms(""), []) + self.assertEqual(item_search.split_search_terms(None), []) + + def test_route_item_query(self): + self.assertEqual( + item_search.route_item_query("Item", item_search.ERP_ITEM_QUERY), + item_search.AV_TOOLS_ITEM_QUERY, + ) + self.assertEqual(item_search.route_item_query("Item", None), item_search.AV_TOOLS_ITEM_QUERY) + self.assertEqual( + item_search.route_item_query("Customer", "frappe.desk.search.search_widget"), + "frappe.desk.search.search_widget", + ) + + def test_item_query_builds_multi_term_conditions(self): + class Meta: + def get_search_fields(self): + return ["item_name", "description"] + + captured = {} + + class FakeDB: + def exists(self, doctype, name): + return True + + def count(self, doctype, cache=True): + return 10 + + def sql(self, query, values, as_dict=False): + captured["query"] = query + captured["values"] = values + captured["as_dict"] = as_dict + return [] + + with patch.object(item_search.frappe, "get_meta", return_value=Meta()), patch.object( + item_search.frappe, "db", FakeDB() + ), patch.object(item_search, "get_filters_cond", return_value=""), patch.object( + item_search, "get_match_cond", return_value="" + ), patch.object( + item_search, + "nowdate", + return_value="2026-05-13", + ): + item_search.item_query.__wrapped__( + doctype="Item", + txt="blue chair", + searchfield="name", + start=0, + page_len=20, + filters={}, + ) + + self.assertIn("item_name like %(txt_0)s and item_name like %(txt_1)s", captured["query"]) + self.assertIn( + "tabItem.description like %(txt_0)s and tabItem.description like %(txt_1)s", + captured["query"], + ) + self.assertEqual(captured["values"]["txt"], "%blue%chair%") + self.assertEqual(captured["values"]["_txt"], "blue") + self.assertEqual(captured["values"]["txt_0"], "%blue%") + self.assertEqual(captured["values"]["txt_1"], "%chair%") diff --git a/av_tools/hooks.py b/av_tools/hooks.py index f9757b5..56df3cd 100644 --- a/av_tools/hooks.py +++ b/av_tools/hooks.py @@ -248,6 +248,8 @@ # ------------------------------ # override_whitelisted_methods = { + "frappe.desk.search.search_link": "av_tools.av_tools_hooks.item_search.search_link", + "frappe.desk.search.search_widget": "av_tools.av_tools_hooks.item_search.search_widget", "frappe.desk.query_report.get_script": "av_tools.av_tools_hooks.query_report.get_script", "erpnext.buying.doctype.purchase_order.purchase_order.update_status": "av_tools.av_tools_hooks.generic_erp_behavior_overrides.update_purchase_order_status", "erpnext.buying.doctype.purchase_order.purchase_order.close_or_unclose_purchase_orders": "av_tools.av_tools_hooks.generic_erp_behavior_overrides.close_or_unclose_purchase_orders",