fix(projects): respect permissions in timesheet billing summary (#58320)

This commit is contained in:
Mihir Kandoi
2026-08-20 15:13:18 +05:30
committed by GitHub
parent 2328e6da94
commit 624d402143
2 changed files with 115 additions and 25 deletions

View File

@@ -2,9 +2,10 @@
# See license.txt
import frappe
from frappe import _
from erpnext.projects.doctype.timesheet.test_timesheet import make_timesheet
from erpnext.projects.report.timesheet_billing_summary.timesheet_billing_summary import execute
from erpnext.projects.report.timesheet_billing_summary.timesheet_billing_summary import execute, group_by
from erpnext.setup.doctype.employee.test_employee import make_employee
from erpnext.tests.utils import ERPNextTestSuite
@@ -28,10 +29,13 @@ class TestTimesheetBillingSummary(ERPNextTestSuite):
self.employee, simulate=True, is_billable=is_billable, project=self.project.name
)
def run_report(self, **extra):
def execute_report(self, **extra):
filters = frappe._dict({"company": "_Test Company", "employee": self.employee})
filters.update(extra)
return execute(filters)[1]
return execute(filters)
def run_report(self, **extra):
return self.execute_report(**extra)[1]
def test_billable_timesheet_row(self):
ts = self.make_ts(is_billable=1)
@@ -53,6 +57,80 @@ class TestTimesheetBillingSummary(ERPNextTestSuite):
self.assertTrue(group_rows, "Grouped project row missing")
self.assertEqual(group_rows[0]["hours"], 2)
def test_report_summary_totals(self):
self.make_ts(is_billable=1)
self.make_ts(is_billable=1)
_columns, data, _message, _chart, report_summary, _skip_total_row = self.execute_report()
summary = {item["label"]: item["value"] for item in report_summary}
self.assertFalse(any(row.get("timesheet") == "'Total'" for row in data))
self.assertEqual(summary[_("Total Working Hours")], 4)
self.assertEqual(summary[_("Total Billing Hours")], 4)
self.assertEqual(summary[_("Total Billing Amount")], 200)
def test_group_by_date_combines_entries_from_same_day(self):
data = [
frappe._dict(date="2026-08-20 09:00:00", hours=2, billing_hours=2, billing_amount=100),
frappe._dict(date="2026-08-20 15:00:00", hours=3, billing_hours=3, billing_amount=150),
]
group_rows = [row for row in group_by(data, "date") if row.get("is_group")]
self.assertEqual(len(group_rows), 1)
self.assertEqual(group_rows[0]["hours"], 5)
def test_report_summary_respects_project_user_permission(self):
denied_project = frappe.get_doc(
{
"doctype": "Project",
"project_name": "_Test TBS Denied",
"company": "_Test Company",
}
).insert()
self.make_ts(is_billable=1)
make_timesheet(
self.employee,
simulate=True,
is_billable=1,
project=denied_project.name,
)
user = frappe.get_doc(
{
"doctype": "User",
"email": "timesheet-summary@example.com",
"first_name": "Timesheet Summary",
"enabled": 1,
"send_welcome_email": 0,
"roles": [{"role": "Projects User"}, {"role": "Accounts User"}],
}
).insert(ignore_permissions=True)
frappe.get_doc(
{
"doctype": "User Permission",
"user": user.name,
"allow": "Project",
"for_value": self.project.name,
}
).insert(ignore_permissions=True)
frappe.clear_cache(user=user.name)
try:
frappe.set_user(user.name)
_columns, data, _message, _chart, report_summary, _skip_total_row = self.execute_report(
group_by="project"
)
finally:
frappe.set_user("Administrator")
summary = {item["label"]: item["value"] for item in report_summary}
group_projects = {row.get("project") for row in data if row.get("is_group")}
self.assertEqual(group_projects, {self.project.name})
self.assertEqual(summary[_("Total Working Hours")], 2)
self.assertEqual(summary[_("Total Billing Hours")], 2)
self.assertEqual(summary[_("Total Billing Amount")], 100)
def test_draft_excluded_unless_requested(self):
ts = make_timesheet(
self.employee, simulate=True, is_billable=1, project=self.project.name, do_not_submit=True

View File

@@ -1,5 +1,6 @@
import frappe
from frappe import _
from frappe.desk.query_report import get_filtered_data
from frappe.model.docstatus import DocStatus
from frappe.utils import getdate
@@ -12,11 +13,14 @@ def execute(filters=None):
filters = frappe._dict(filters or {})
columns = get_columns(filters, group_fieldname)
data = get_data(filters, group_fieldname)
data = get_data(filters)
data = get_filtered_data("Timesheet", columns, data, frappe.session.user)
report_summary = get_report_summary(data)
# the report totals itself in `get_total_row`: frappe's total row counts a group and its
# children as separate rows, and the datatable hides it anyway in tree mode
return columns, data, None, None, None, 1
if group_fieldname:
data = group_by(data, group_fieldname)
return columns, data, None, None, report_summary, 1
def get_columns(filters, group_fieldname=None):
@@ -86,7 +90,7 @@ def get_columns(filters, group_fieldname=None):
return columns
def get_data(filters, group_fieldname=None):
def get_data(filters):
_filters = []
if filters.get("employee"):
_filters.append(("employee", "=", filters.get("employee")))
@@ -117,13 +121,6 @@ def get_data(filters, group_fieldname=None):
order_by="`tabTimesheet Detail`.from_time",
)
if not data:
return data
if group_fieldname:
data = group_by(data, group_fieldname)
data.append(get_total_row(data))
return data
@@ -170,17 +167,32 @@ def get_group_value(row, fieldname):
return getdate(value) if fieldname == "date" and value else value
def get_total_row(data):
total_row = dict.fromkeys(VALUE_FIELDNAMES, 0.0)
# quoted so that the Link column renders it as plain text instead of a broken link
total_row["timesheet"] = f"'{_('Total')}'"
total_row["indent"] = 0
def get_report_summary(data):
if not data:
return None
totals = dict.fromkeys(VALUE_FIELDNAMES, 0.0)
for row in data:
# a group already carries its children's values: adding both would count them twice
if row.get("indent"):
continue
for value_fieldname in VALUE_FIELDNAMES:
total_row[value_fieldname] += row.get(value_fieldname) or 0
totals[value_fieldname] += row.get(value_fieldname) or 0
return total_row
return [
{
"value": totals["hours"],
"indicator": "Blue",
"label": _("Total Working Hours"),
"datatype": "Float",
},
{
"value": totals["billing_hours"],
"indicator": "Blue",
"label": _("Total Billing Hours"),
"datatype": "Float",
},
{
"value": totals["billing_amount"],
"indicator": "Green",
"label": _("Total Billing Amount"),
"datatype": "Currency",
},
]