mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-24 20:46:38 +00:00
fix(projects): respect permissions in timesheet billing summary (#58320)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user