diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml new file mode 100644 index 0000000..70933a6 --- /dev/null +++ b/.github/workflows/backport.yml @@ -0,0 +1,68 @@ +name: Backport + +on: + pull_request_target: + types: [closed, labeled] + +permissions: + contents: write + pull-requests: write + +jobs: + backport: + name: Backport PR + if: > + github.event.pull_request.merged == true + && contains(join(github.event.pull_request.labels.*.name, ','), 'backport-to-') + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Create backport PR(s) + uses: korthout/backport-action@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + label_pattern: "^backport-to-(.+)$" + merge_commits: "skip" + pull_title: "[Backport ${target_branch}] ${pull_title}" + pull_description: | + Backport of #${pull_number} to `${target_branch}`. + + --- + + ${pull_description} + copy_requested_reviewers: true + copy_assignees: true + copy_labels_pattern: "^(?!backport-to-)" + auto_merge_enabled: ${{ contains(join(github.event.pull_request.labels.*.name, ','), 'backport-auto-merge') }} + + - name: Report backport status + if: failure() + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const pullNumber = context.payload.pull_request.number; + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pullNumber, + body: [ + "⚠️ **Backport failed!**", + "", + "Automatic backport could not be completed. This is usually caused by cherry-pick conflicts or a missing target branch.", + "", + "Manual backport example:", + "", + "```bash", + "git checkout ", + `git cherry-pick -x ${context.payload.pull_request.merge_commit_sha}`, + "```", + ].join("\\n"), + }); diff --git a/.github/workflows/tag-and-promote-from-pr-label.yml b/.github/workflows/tag-and-promote-from-pr-label.yml new file mode 100644 index 0000000..9daea1d --- /dev/null +++ b/.github/workflows/tag-and-promote-from-pr-label.yml @@ -0,0 +1,178 @@ +name: Tag and promote from PR label + +on: + pull_request_target: + types: + - closed + - labeled + +permissions: + contents: write + pull-requests: read + +jobs: + tag-and-promote: + if: > + github.event.pull_request.merged == true && + ( + github.event.action == 'closed' || + ( + github.event.action == 'labeled' && + startsWith(github.event.label.name, 'promote/') + ) + ) + runs-on: ubuntu-latest + + steps: + - name: Determine target branch from PR labels + id: target + uses: actions/github-script@v8 + with: + script: | + const labels = context.payload.pull_request.labels.map(label => label.name); + + const mapping = { + "promote/version-15": "version-15", + "promote/version-16": "version-16", + "promote/production": "production" + }; + + const matchedLabels = labels.filter(label => mapping[label]); + + if (matchedLabels.length === 0) { + core.info( + `No promote target label found. Skipping promotion. Add one of: ${Object.keys(mapping).join(", ")}` + ); + core.setOutput("should_promote", "false"); + return; + } + + if (matchedLabels.length > 1) { + core.setFailed( + `Multiple promote target labels found: ${matchedLabels.join(", ")}. Keep only one.` + ); + return; + } + + const matchedLabel = matchedLabels[0]; + + core.setOutput("should_promote", "true"); + core.setOutput("target_branch", mapping[matchedLabel]); + core.setOutput("matched_label", matchedLabel); + + - name: Checkout merged commit + if: steps.target.outputs.should_promote == 'true' + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.merge_commit_sha }} + fetch-depth: 0 + + - name: Read version from av_tools.__version__ + if: steps.target.outputs.should_promote == 'true' + id: version + shell: bash + run: | + VERSION=$(python - <<'PY' + import re + from pathlib import Path + + init_file = Path("av_tools/__init__.py") + content = init_file.read_text() + + match = re.search(r'^__version__\s*=\s*["\']([^"\']+)["\']', content, re.M) + + if not match: + raise SystemExit("Could not find __version__ in av_tools/__init__.py") + + print(match.group(1)) + PY + ) + + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" + + - name: Configure git user + if: steps.target.outputs.should_promote == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Create and push version tag + if: steps.target.outputs.should_promote == 'true' + shell: bash + run: | + git fetch --tags + + TAG="${{ steps.version.outputs.tag }}" + CURRENT_COMMIT="$(git rev-parse HEAD)" + + if git rev-parse "$TAG" >/dev/null 2>&1; then + TAG_COMMIT="$(git rev-list -n 1 "$TAG")" + + if [ "$TAG_COMMIT" != "$CURRENT_COMMIT" ]; then + echo "Tag $TAG already exists but points to $TAG_COMMIT, not current merged commit $CURRENT_COMMIT." + exit 1 + fi + + echo "Tag $TAG already exists and points to the current merged commit. Skipping tag creation." + exit 0 + fi + + git tag -a "$TAG" -m "Release $TAG" + git push origin "$TAG" + + - name: Create GitHub release with generated title and notes + if: steps.target.outputs.should_promote == 'true' + uses: actions/github-script@v8 + env: + TAG_NAME: ${{ steps.version.outputs.tag }} + TARGET_COMMITISH: ${{ github.event.pull_request.merge_commit_sha }} + with: + script: | + const tagName = process.env.TAG_NAME; + const targetCommitish = process.env.TARGET_COMMITISH; + const { owner, repo } = context.repo; + + try { + const existingRelease = await github.rest.repos.getReleaseByTag({ + owner, + repo, + tag: tagName + }); + + core.info( + `Release already exists for ${tagName}: ${existingRelease.data.html_url}. Skipping release creation.` + ); + return; + } catch (error) { + if (error.status !== 404) { + throw error; + } + } + + const generatedNotes = await github.rest.repos.generateReleaseNotes({ + owner, + repo, + tag_name: tagName, + target_commitish: targetCommitish, + previous_tag_name: undefined + }); + + const release = await github.rest.repos.createRelease({ + owner, + repo, + tag_name: tagName, + target_commitish: targetCommitish, + name: generatedNotes.data.name, + body: generatedNotes.data.body, + draft: false, + prerelease: false + }); + + core.info(`Created release: ${release.data.html_url}`); + + - name: Promote merged commit to target branch + if: steps.target.outputs.should_promote == 'true' + shell: bash + run: | + git push origin HEAD:${{ steps.target.outputs.target_branch }} diff --git a/av_tools/__init__.py b/av_tools/__init__.py index f102a9c..30c70d3 100644 --- a/av_tools/__init__.py +++ b/av_tools/__init__.py @@ -1 +1 @@ -__version__ = "0.0.1" +__version__ = "15.2.0" 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/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}`, + }); + }); + } + } +}); 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",