Compare commits

...

10 Commits

Author SHA1 Message Date
Sagar Vora
48983c5ef0 Merge pull request #58756 from frappe/mergify/bp/version-16-hotfix/pr-58724
fix: minor improvements to financial report template validation (backport #58724)
2026-09-04 14:22:37 +05:30
Abdeali Chharchhodawala
d23b407ec7 fix: minor improvements to financial report template validation (#58724)
* fix: address review comments on financial report template validation

* refactor: minor fixes

(cherry picked from commit 1b7da82669)
2026-09-04 08:31:55 +00:00
Sudharsanan Ashok
770726d8f5 Merge pull request #58754 from frappe/mergify/bp/version-16-hotfix/pr-58740
fix: check material request price list permission (backport #58740)
2026-09-04 13:07:14 +05:30
Pandiyan P
e5b1ff667d fix: check material request price list permission (#58740)
(cherry picked from commit 0b1f1d6851)
2026-09-04 07:33:18 +00:00
Sudharsanan Ashok
d8236548be Merge pull request #58749 from frappe/mergify/bp/version-16-hotfix/pr-58736
fix(batch): show Expired status only after expiry date has passed (backport #58736)
2026-09-04 13:00:00 +05:30
mergify[bot]
f75601e9b1 fix(timesheet): handle empty allowed projects (backport #58745) (#58746)
Co-authored-by: Krishna Pramod Shirsath <91021227+krishna-254@users.noreply.github.com>
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-09-04 07:02:33 +00:00
Pandiyan P
0684599bdb fix(batch): show Expired status only after expiry date has passed (#58736)
Co-authored-by: Ajish18 <ajishiyappan1@gmail.com>
(cherry picked from commit 00f04fc084)
2026-09-04 06:51:16 +00:00
mergify[bot]
a1c8dc878d fix(stock): allow creating stock closing balances (backport #58590) (#58685)
* fix(stock): allow creating stock closing balances (#58590)

(cherry picked from commit 2918e98a2b)

# Conflicts:
#	erpnext/stock/doctype/stock_closing_entry/test_stock_closing_entry.py

* chore: fix conflicts

Removed redundant test cases and cleaned up the test structure for StockClosingEntry.

---------

Co-authored-by: Krishna Pramod Shirsath <91021227+krishna-254@users.noreply.github.com>
Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-09-03 10:29:34 +00:00
mergify[bot]
dfb64d7635 fix(setup): strict permissions for transaction deletion record (backport #58687) (#58723)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-09-03 15:07:48 +05:30
mergify[bot]
b0ddca0455 ci: authenticate github clones in install.sh (backport #58718) (#58720)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-09-03 09:27:42 +00:00
13 changed files with 152 additions and 56 deletions

View File

@@ -4,6 +4,36 @@ set -e
cd ~ || exit
# Authenticate git against github.com with the job token: anonymous git-over-HTTPS from the
# runners gets throttled to a 401, which kills whichever clone is in flight — the frappe fetch
# below, or payments under `bench get-app`. See the PR description.
#
# A credential helper rather than a url.insteadOf rewrite, because `git clone` PERSISTS a
# rewritten URL into the new repo's .git/config: an insteadOf would leave the token sitting in
# apps/payments/.git/config on the runner. A helper is consulted only when github.com actually
# challenges, and leaves the stored remote URL untouched. Passing it through GIT_CONFIG_* keeps
# the token out of ~/.gitconfig too, and child processes inherit it (bench shells out to git).
ci_github_token=${CI_GITHUB_TOKEN:-${GITHUB_TOKEN:-}}
if [ -n "$ci_github_token" ]; then
export CI_GITHUB_TOKEN="$ci_github_token"
export GIT_CONFIG_COUNT=3
# Reset first: git runs EVERY configured helper and calls `store` on them after a successful
# auth, so a `credential.helper=store` inherited from the image's gitconfig would write the
# token to ~/.git-credentials. An empty value clears the list before ours is added.
export GIT_CONFIG_KEY_0="credential.helper"
export GIT_CONFIG_VALUE_0=""
export GIT_CONFIG_KEY_1="credential.https://github.com.username"
export GIT_CONFIG_VALUE_1="x-access-token"
export GIT_CONFIG_KEY_2="credential.https://github.com.helper"
# Single-quoted: $CI_GITHUB_TOKEN is expanded by the shell git runs the helper in, so the
# token is read from the environment at call time and never stored anywhere. Answering only
# `get` makes the helper inert for git's `store`/`erase` calls.
export GIT_CONFIG_VALUE_2='!f() { test "$1" = get && echo "password=$CI_GITHUB_TOKEN"; }; f'
fi
# Whatever happens, never sit on a credential prompt: fail fast and legibly instead.
export GIT_TERMINAL_PROMPT=0
githubbranch=${GITHUB_BASE_REF:-${GITHUB_REF##*/}}
frappeuser=${FRAPPE_USER:-"frappe"}
frappecommitish=${FRAPPE_BRANCH:-$githubbranch}

View File

@@ -105,6 +105,8 @@ jobs:
env:
DB: mariadb
TYPE: server
# Anonymous git to github.com gets throttled to a 401; authenticate the clones.
CI_GITHUB_TOKEN: ${{ github.token }}
- name: Run Patch Tests
run: |

View File

@@ -129,6 +129,8 @@ jobs:
TYPE: server
FRAPPE_USER: ${{ github.event.inputs.user }}
FRAPPE_BRANCH: ${{ github.event.inputs.branch }}
# Anonymous git to github.com gets throttled to a 401; authenticate the clones.
CI_GITHUB_TOKEN: ${{ github.token }}
- name: Run Tests
run: |

View File

@@ -102,6 +102,8 @@ jobs:
TYPE: server
FRAPPE_USER: ${{ github.event.inputs.user }}
FRAPPE_BRANCH: ${{ github.event.client_payload.sha || github.event.inputs.branch }}
# Anonymous git to github.com gets throttled to a 401; authenticate the clones.
CI_GITHUB_TOKEN: ${{ github.token }}
DB_HOST: 127.0.0.1
DB_USER_HOST: '%'
WKHTMLTOX_DEB: /tmp/wkhtmltox.deb

View File

@@ -193,8 +193,10 @@ class TemplateStructureValidator(Validator):
if not row.calculation_formula:
result.add_error(
ValidationIssue(
message=_("{0} is required for {1}").format(
get_formula_field_label(row.data_source), row.data_source
message=_("{0} is required when {1} is {2}").format(
get_formula_field_label(row.data_source),
row.meta.get_translated_label("data_source"),
_(row.data_source),
),
row_idx=row.idx,
)
@@ -222,7 +224,14 @@ class DependencyValidator(Validator):
for row in self.template.rows:
if row.reference_code and row.data_source == "Calculated Amount" and row.calculation_formula:
deps = extract_reference_codes_from_formula(row.calculation_formula, list(available_codes))
# skip self-reference, `CalculationFormulaValidator` already reports it
deps = [
code
for code in extract_reference_codes_from_formula(
row.calculation_formula, list(available_codes)
)
if code != row.reference_code
]
if deps:
graph[row.reference_code] = deps
@@ -284,7 +293,9 @@ class DependencyValidator(Validator):
row_idx = self._get_row_idx(ref_code)
result.add_error(
ValidationIssue(
message=_("Line References undefined in Formula: {0}").format(", ".join(undefined)),
message=_("Line references undefined in {0}: {1}").format(
get_formula_field_label("Calculated Amount"), ", ".join(undefined)
),
row_idx=row_idx,
)
)
@@ -311,17 +322,6 @@ class CalculationFormulaValidator(Validator):
if row.data_source != "Calculated Amount":
return result
if not row.calculation_formula:
result.add_error(
ValidationIssue(
message=_("{0} is required for Calculated Amount").format(
get_formula_field_label(row.data_source)
),
row_idx=row.idx,
)
)
return result
formula = self._preprocess_formula(row.calculation_formula)
row.calculation_formula = formula
@@ -346,16 +346,6 @@ class CalculationFormulaValidator(Validator):
)
)
# Check undefined references
undefined = set(refs) - set(available_codes)
if undefined:
result.add_error(
ValidationIssue(
message=_("Formula references undefined codes: {0}").format(", ".join(undefined)),
row_idx=row.idx,
)
)
# Try to evaluate with dummy values
eval_error = self._test_formula_evaluation(formula, available_codes)
if eval_error:
@@ -418,17 +408,6 @@ class AccountFilterValidator(Validator):
if row.data_source != "Account Data":
return result
if not row.calculation_formula:
result.add_error(
ValidationIssue(
message=_("{0} is required for Account Data").format(
get_formula_field_label(row.data_source)
),
row_idx=row.idx,
)
)
return result
try:
filter_config = json.loads(row.calculation_formula)
error = self._validate_filter_structure(
@@ -440,7 +419,9 @@ class AccountFilterValidator(Validator):
if error:
result.add_error(
ValidationIssue(
message=_("{0}: {1}").format(get_formula_field_label(row.data_source), error),
message=_("[{0}] {1}", context="Financial Report Template").format(
get_formula_field_label(row.data_source), error
),
row_idx=row.idx,
)
)
@@ -448,8 +429,9 @@ class AccountFilterValidator(Validator):
except json.JSONDecodeError as e:
result.add_error(
ValidationIssue(
message=_("{0}: Invalid JSON format: {1}").format(
get_formula_field_label(row.data_source), str(e)
message=_("[{0}] {1}", context="Financial Report Template").format(
get_formula_field_label(row.data_source),
_("Invalid JSON format: {0}").format(str(e)),
),
row_idx=row.idx,
)
@@ -555,8 +537,9 @@ class FormulaValidator(Validator):
frappe.clear_last_message()
if isinstance(e, frappe.PermissionError):
message = _("{0}: Method '{1}' must be whitelisted and permit GET requests").format(
get_formula_field_label(row.data_source), api_path
message = _("[{0}] {1}", context="Financial Report Template").format(
get_formula_field_label(row.data_source),
_("Method '{0}' must be whitelisted and permit GET requests").format(api_path),
)
else:
message = _("Could not validate {0}: {1}").format(

View File

@@ -1,7 +1,7 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import datetime
import unittest
from unittest.mock import patch
import frappe
from frappe.utils import add_to_date, now_datetime, nowdate
@@ -9,12 +9,20 @@ from frappe.utils import add_to_date, now_datetime, nowdate
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.projects.doctype.task.test_task import create_task
from erpnext.projects.doctype.timesheet.timesheet import OverlapError, make_sales_invoice
from erpnext.projects.doctype.timesheet.timesheet import (
OverlapError,
get_projectwise_timesheet_data,
make_sales_invoice,
)
from erpnext.setup.doctype.employee.test_employee import make_employee
from erpnext.tests.utils import ERPNextTestSuite
class TestTimesheet(ERPNextTestSuite):
def test_get_projectwise_timesheet_data_without_allowed_projects(self):
with patch("frappe.get_list", side_effect=[["TS-0001"], []]):
self.assertEqual(get_projectwise_timesheet_data(), [])
def test_timesheet_post_update(self):
frappe.get_doc(
{

View File

@@ -335,10 +335,14 @@ def get_projectwise_timesheet_data(project=None, parent=None, from_time=None, to
& (tsd.is_billable == 1)
& tsd.sales_invoice.isnull()
& (tsd.parent.isin(allowed_timesheets))
& ((tsd.project.isin(allowed_projects)) | (tsd.project.isnull()))
)
)
if allowed_projects:
query = query.where((tsd.project.isin(allowed_projects)) | (tsd.project.isnull()))
else:
query = query.where(tsd.project.isnull())
if project:
query = query.where(tsd.project == project)
if parent:

View File

@@ -1082,6 +1082,8 @@ def get_billing_shipping_address(name, billing_address=None, shipping_address=No
@frappe.whitelist()
def create_transaction_deletion_request(company):
frappe.only_for("System Manager")
# User Permission check
frappe.has_permission("Company", ptype="delete", doc=company, throw=True)
from erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record import (
is_deletion_doc_running,
@@ -1090,6 +1092,7 @@ def create_transaction_deletion_request(company):
is_deletion_doc_running(company)
tdr = frappe.get_doc({"doctype": "Transaction Deletion Record", "company": company})
tdr.flags.ignore_permissions = 1
tdr.insert()
tdr.generate_to_delete_list()

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"autoname": "TDL.####",
"creation": "2021-04-06 20:17:18.404716",
"doctype": "DocType",
@@ -166,19 +167,18 @@
"read_only": 1
}
],
"in_create": 1,
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2025-11-18 15:02:46.427695",
"modified": "2026-09-02 20:32:19.679290",
"modified_by": "Administrator",
"module": "Setup",
"name": "Transaction Deletion Record",
"naming_rule": "Expression (old style)",
"naming_rule": "Expression",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
@@ -186,7 +186,6 @@
"report": 1,
"role": "System Manager",
"share": 1,
"submit": 1,
"write": 1
}
],

View File

@@ -5,12 +5,12 @@ frappe.listview_settings["Batch"] = {
return [__("Disabled"), "gray", "disabled,=,1"];
} else if (
doc.expiry_date &&
frappe.datetime.get_diff(doc.expiry_date, frappe.datetime.nowdate()) <= 0
frappe.datetime.get_diff(doc.expiry_date, frappe.datetime.nowdate()) < 0
) {
return [
__("Expired"),
"red",
"expiry_date,not in,|expiry_date,<=,Today|batch_qty,>,0|disabled,=,0",
"expiry_date,not in,|expiry_date,<,Today|batch_qty,>,0|disabled,=,0",
];
} else if (!doc.batch_qty) {
return [__("Empty"), "gray", "batch_qty,=,0|disabled,=,0"];

View File

@@ -101,8 +101,27 @@ frappe.ui.form.on("Material Request", {
erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype);
if (!frm.doc.buying_price_list) {
const buying_price_list = frappe.defaults.get_default("buying_price_list");
if (frappe.has_permission("Price List", "read", buying_price_list)) {
frm.set_value("buying_price_list", buying_price_list);
if (buying_price_list) {
const docname = frm.doc.name;
frappe.call({
type: "GET",
method: "frappe.client.has_permission",
no_spinner: true,
args: {
doctype: "Price List",
docname: buying_price_list,
perm_type: "read",
},
callback: ({ message }) => {
if (
message?.has_permission &&
frm.doc.name === docname &&
!frm.doc.buying_price_list
) {
frm.set_value("buying_price_list", buying_price_list);
}
},
});
}
}
},

View File

@@ -189,7 +189,7 @@ class StockClosingEntry(Document):
new_doc.posting_datetime = get_combine_datetime(self.to_date, new_doc.posting_time)
new_doc.stock_closing_entry = self.name
new_doc.company = self.company
new_doc.save()
new_doc.save(ignore_permissions=True)
def get_prepared_data(self):
if attachments := get_attachments(self.doctype, self.name):

View File

@@ -1,14 +1,22 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
from unittest.mock import patch
import frappe
from frappe.core.doctype.user_permission.test_user_permission import create_user
from frappe.utils import today
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.tests.utils import ERPNextTestSuite
# On ERPNextTestSuite, the doctype test records and all
# link-field test record depdendencies are recursively loaded
# Use these module variables to add/remove to/from that list
COMPANY = "_Test Company"
WAREHOUSE = "_Test Warehouse - _TC"
class TestStockClosingEntry(ERPNextTestSuite):
"""
@@ -16,4 +24,40 @@ class TestStockClosingEntry(ERPNextTestSuite):
Use this class for testing interactions between multiple components.
"""
pass
def make_stock_closing_entry(self, from_date, to_date):
entry = frappe.get_doc(
doctype="Stock Closing Entry",
company=COMPANY,
from_date=from_date,
to_date=to_date,
).submit()
self.last_closing_entry = entry.name
return entry
def test_non_administrator_can_generate_closing_balance(self):
item = make_item(properties={"is_stock_item": 1}).name
with patch("erpnext.stock.doctype.stock_closing_entry.stock_closing_entry.enqueue"):
entry = self.make_stock_closing_entry(today(), today())
user = create_user("test_stock_closing_balance@example.com", "Stock User")
self.assertFalse(frappe.has_permission("Stock Closing Balance", "create", user=user.name))
balance = frappe._dict(
item_code=item,
warehouse=WAREHOUSE,
actual_qty=1,
stock_value_difference=100,
fifo_queue=None,
)
with (
patch(
"erpnext.stock.doctype.stock_closing_entry.stock_closing_entry.StockClosing"
) as stock_closing,
self.set_user(user.name),
):
stock_closing.return_value.get_stock_closing_entries.return_value = {(item, WAREHOUSE): balance}
entry.create_stock_closing_balance_entries()
self.assertTrue(
frappe.db.exists("Stock Closing Balance", {"stock_closing_entry": entry.name, "item_code": item})
)