您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符
 
 
 
 
 
 

779 行
21 KiB

  1. # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
  2. # License: MIT. See LICENSE
  3. import frappe
  4. import os
  5. import json
  6. from frappe import _
  7. from frappe.modules import scrub, get_module_path
  8. from frappe.utils import (
  9. flt,
  10. cint,
  11. cstr,
  12. get_html_format,
  13. get_url_to_form,
  14. gzip_decompress,
  15. format_duration,
  16. )
  17. from frappe.model.utils import render_include
  18. from frappe.translate import send_translations
  19. import frappe.desk.reportview
  20. from frappe.permissions import get_role_permissions
  21. from datetime import timedelta
  22. from frappe.core.utils import ljust_list
  23. def get_report_doc(report_name):
  24. doc = frappe.get_doc("Report", report_name)
  25. doc.custom_columns = []
  26. if doc.report_type == "Custom Report":
  27. custom_report_doc = doc
  28. reference_report = custom_report_doc.reference_report
  29. doc = frappe.get_doc("Report", reference_report)
  30. doc.custom_report = report_name
  31. if custom_report_doc.json:
  32. data = json.loads(custom_report_doc.json)
  33. if data:
  34. doc.custom_columns = data["columns"]
  35. doc.is_custom_report = True
  36. if not doc.is_permitted():
  37. frappe.throw(
  38. _("You don't have access to Report: {0}").format(report_name),
  39. frappe.PermissionError,
  40. )
  41. if not frappe.has_permission(doc.ref_doctype, "report"):
  42. frappe.throw(
  43. _("You don't have permission to get a report on: {0}").format(
  44. doc.ref_doctype
  45. ),
  46. frappe.PermissionError,
  47. )
  48. if doc.disabled:
  49. frappe.throw(_("Report {0} is disabled").format(report_name))
  50. return doc
  51. def get_report_result(report, filters):
  52. if report.report_type == "Query Report":
  53. res = report.execute_query_report(filters)
  54. elif report.report_type == "Script Report":
  55. res = report.execute_script_report(filters)
  56. elif report.report_type == "Custom Report":
  57. ref_report = get_report_doc(report.report_name)
  58. res = get_report_result(ref_report, filters)
  59. return res
  60. @frappe.read_only()
  61. def generate_report_result(report, filters=None, user=None, custom_columns=None, report_settings=None):
  62. user = user or frappe.session.user
  63. filters = filters or []
  64. if filters and isinstance(filters, str):
  65. filters = json.loads(filters)
  66. res = get_report_result(report, filters) or []
  67. columns, result, message, chart, report_summary, skip_total_row = ljust_list(res, 6)
  68. columns = [get_column_as_dict(col) for col in columns]
  69. report_column_names = [col["fieldname"] for col in columns]
  70. # convert to list of dicts
  71. result = normalize_result(result, columns)
  72. if report.custom_columns:
  73. # saved columns (with custom columns / with different column order)
  74. columns = report.custom_columns
  75. # unsaved custom_columns
  76. if custom_columns:
  77. for custom_column in custom_columns:
  78. columns.insert(custom_column["insert_after_index"] + 1, custom_column)
  79. # all columns which are not in original report
  80. report_custom_columns = [column for column in columns if column["fieldname"] not in report_column_names]
  81. if report_custom_columns:
  82. result = add_custom_column_data(report_custom_columns, result)
  83. if result:
  84. result = get_filtered_data(report.ref_doctype, columns, result, user)
  85. if cint(report.add_total_row) and result and not skip_total_row:
  86. result = add_total_row(result, columns, report_settings=report_settings)
  87. return {
  88. "result": result,
  89. "columns": columns,
  90. "message": message,
  91. "chart": chart,
  92. "report_summary": report_summary,
  93. "skip_total_row": skip_total_row or 0,
  94. "status": None,
  95. "execution_time": frappe.cache().hget("report_execution_time", report.name)
  96. or 0,
  97. }
  98. def normalize_result(result, columns):
  99. # Converts to list of dicts from list of lists/tuples
  100. data = []
  101. column_names = [column["fieldname"] for column in columns]
  102. if result and isinstance(result[0], (list, tuple)):
  103. for row in result:
  104. row_obj = {}
  105. for idx, column_name in enumerate(column_names):
  106. row_obj[column_name] = row[idx]
  107. data.append(row_obj)
  108. else:
  109. data = result
  110. return data
  111. @frappe.whitelist()
  112. def background_enqueue_run(report_name, filters=None, user=None):
  113. """run reports in background"""
  114. if not user:
  115. user = frappe.session.user
  116. report = get_report_doc(report_name)
  117. track_instance = frappe.get_doc(
  118. {
  119. "doctype": "Prepared Report",
  120. "report_name": report_name,
  121. # This looks like an insanity but, without this it'd be very hard to find Prepared Reports matching given condition
  122. # We're ensuring that spacing is consistent. e.g. JS seems to put no spaces after ":", Python on the other hand does.
  123. "filters": json.dumps(json.loads(filters)),
  124. "ref_report_doctype": report_name,
  125. "report_type": report.report_type,
  126. "query": report.query,
  127. "module": report.module,
  128. }
  129. )
  130. track_instance.insert(ignore_permissions=True)
  131. frappe.db.commit()
  132. track_instance.enqueue_report()
  133. return {
  134. "name": track_instance.name,
  135. "redirect_url": get_url_to_form("Prepared Report", track_instance.name),
  136. }
  137. @frappe.whitelist()
  138. def get_script(report_name):
  139. report = get_report_doc(report_name)
  140. module = report.module or frappe.db.get_value(
  141. "DocType", report.ref_doctype, "module"
  142. )
  143. is_custom_module = frappe.get_cached_value("Module Def", module, "custom")
  144. # custom modules are virtual modules those exists in DB but not in disk.
  145. module_path = '' if is_custom_module else get_module_path(module)
  146. report_folder = module_path and os.path.join(module_path, "report", scrub(report.name))
  147. script_path = report_folder and os.path.join(report_folder, scrub(report.name) + ".js")
  148. print_path = report_folder and os.path.join(report_folder, scrub(report.name) + ".html")
  149. script = None
  150. if os.path.exists(script_path):
  151. with open(script_path, "r") as f:
  152. script = f.read()
  153. script += f"\n\n//# sourceURL={scrub(report.name)}.js"
  154. html_format = get_html_format(print_path)
  155. if not script and report.javascript:
  156. script = report.javascript
  157. script += f"\n\n//# sourceURL={scrub(report.name)}__custom"
  158. if not script:
  159. script = "frappe.query_reports['%s']={}" % report_name
  160. # load translations
  161. if frappe.lang != "en":
  162. send_translations(frappe.get_lang_dict("report", report_name))
  163. return {
  164. "script": render_include(script),
  165. "html_format": html_format,
  166. "execution_time": frappe.cache().hget("report_execution_time", report_name)
  167. or 0,
  168. }
  169. @frappe.whitelist()
  170. @frappe.read_only()
  171. def run(report_name, filters=None, user=None, ignore_prepared_report=False, custom_columns=None, report_settings=None):
  172. report = get_report_doc(report_name)
  173. if not user:
  174. user = frappe.session.user
  175. if not frappe.has_permission(report.ref_doctype, "report"):
  176. frappe.msgprint(
  177. _("Must have report permission to access this report."),
  178. raise_exception=True,
  179. )
  180. result = None
  181. if (
  182. report.prepared_report
  183. and not report.disable_prepared_report
  184. and not ignore_prepared_report
  185. and not custom_columns
  186. ):
  187. if filters:
  188. if isinstance(filters, str):
  189. filters = json.loads(filters)
  190. dn = filters.get("prepared_report_name")
  191. filters.pop("prepared_report_name", None)
  192. else:
  193. dn = ""
  194. result = get_prepared_report_result(report, filters, dn, user)
  195. else:
  196. result = generate_report_result(report, filters, user, custom_columns, report_settings)
  197. result["add_total_row"] = report.add_total_row and not result.get(
  198. "skip_total_row", False
  199. )
  200. return result
  201. def add_custom_column_data(custom_columns, result):
  202. custom_column_data = get_data_for_custom_report(custom_columns)
  203. for column in custom_columns:
  204. key = (column.get('doctype'), column.get('fieldname'))
  205. if key in custom_column_data:
  206. for row in result:
  207. row_reference = row.get(column.get('link_field'))
  208. # possible if the row is empty
  209. if not row_reference:
  210. continue
  211. row[column.get('fieldname')] = custom_column_data.get(key).get(row_reference)
  212. return result
  213. def get_prepared_report_result(report, filters, dn="", user=None):
  214. latest_report_data = {}
  215. doc = None
  216. if dn:
  217. # Get specified dn
  218. doc = frappe.get_doc("Prepared Report", dn)
  219. else:
  220. # Only look for completed prepared reports with given filters.
  221. doc_list = frappe.get_all(
  222. "Prepared Report",
  223. filters={
  224. "status": "Completed",
  225. "filters": json.dumps(filters),
  226. "owner": user,
  227. "report_name": report.get("custom_report") or report.get("report_name"),
  228. },
  229. order_by="creation desc",
  230. )
  231. if doc_list:
  232. # Get latest
  233. doc = frappe.get_doc("Prepared Report", doc_list[0])
  234. if doc:
  235. try:
  236. # Prepared Report data is stored in a GZip compressed JSON file
  237. attached_file_name = frappe.db.get_value(
  238. "File",
  239. {"attached_to_doctype": doc.doctype, "attached_to_name": doc.name},
  240. "name",
  241. )
  242. attached_file = frappe.get_doc("File", attached_file_name)
  243. compressed_content = attached_file.get_content()
  244. uncompressed_content = gzip_decompress(compressed_content)
  245. data = json.loads(uncompressed_content.decode("utf-8"))
  246. if data:
  247. columns = json.loads(doc.columns) if doc.columns else data[0]
  248. for column in columns:
  249. if isinstance(column, dict) and column.get("label"):
  250. column["label"] = _(column["label"])
  251. latest_report_data = {"columns": columns, "result": data}
  252. except Exception:
  253. frappe.log_error(frappe.get_traceback())
  254. frappe.delete_doc("Prepared Report", doc.name)
  255. frappe.db.commit()
  256. doc = None
  257. latest_report_data.update({"prepared_report": True, "doc": doc})
  258. return latest_report_data
  259. @frappe.whitelist()
  260. def export_query():
  261. """export from query reports"""
  262. data = frappe._dict(frappe.local.form_dict)
  263. data.pop("cmd", None)
  264. data.pop("csrf_token", None)
  265. if isinstance(data.get("filters"), str):
  266. filters = json.loads(data["filters"])
  267. if data.get("report_name"):
  268. report_name = data["report_name"]
  269. frappe.permissions.can_export(
  270. frappe.get_cached_value("Report", report_name, "ref_doctype"),
  271. raise_exception=True,
  272. )
  273. file_format_type = data.get("file_format_type")
  274. custom_columns = frappe.parse_json(data.get("custom_columns", "[]"))
  275. include_indentation = data.get("include_indentation")
  276. visible_idx = data.get("visible_idx")
  277. if isinstance(visible_idx, str):
  278. visible_idx = json.loads(visible_idx)
  279. if file_format_type == "Excel":
  280. data = run(report_name, filters, custom_columns=custom_columns)
  281. data = frappe._dict(data)
  282. if not data.columns:
  283. frappe.respond_as_web_page(
  284. _("No data to export"),
  285. _("You can try changing the filters of your report."),
  286. )
  287. return
  288. columns = get_columns_dict(data.columns)
  289. from frappe.utils.xlsxutils import make_xlsx
  290. data["result"] = handle_duration_fieldtype_values(
  291. data.get("result"), data.get("columns")
  292. )
  293. xlsx_data, column_widths = build_xlsx_data(columns, data, visible_idx, include_indentation)
  294. xlsx_file = make_xlsx(xlsx_data, "Query Report", column_widths=column_widths)
  295. frappe.response["filename"] = report_name + ".xlsx"
  296. frappe.response["filecontent"] = xlsx_file.getvalue()
  297. frappe.response["type"] = "binary"
  298. def handle_duration_fieldtype_values(result, columns):
  299. for i, col in enumerate(columns):
  300. fieldtype = None
  301. if isinstance(col, str):
  302. col = col.split(":")
  303. if len(col) > 1:
  304. if col[1]:
  305. fieldtype = col[1]
  306. if "/" in fieldtype:
  307. fieldtype, options = fieldtype.split("/")
  308. else:
  309. fieldtype = "Data"
  310. else:
  311. fieldtype = col.get("fieldtype")
  312. if fieldtype == "Duration":
  313. for entry in range(0, len(result)):
  314. row = result[entry]
  315. if isinstance(row, dict):
  316. val_in_seconds = row[col.fieldname]
  317. if val_in_seconds:
  318. duration_val = format_duration(val_in_seconds)
  319. row[col.fieldname] = duration_val
  320. else:
  321. val_in_seconds = row[i]
  322. if val_in_seconds:
  323. duration_val = format_duration(val_in_seconds)
  324. row[i] = duration_val
  325. return result
  326. def build_xlsx_data(columns, data, visible_idx, include_indentation, ignore_visible_idx=False):
  327. result = [[]]
  328. column_widths = []
  329. for column in data.columns:
  330. if column.get("hidden"):
  331. continue
  332. result[0].append(column.get("label"))
  333. column_width = cint(column.get('width', 0))
  334. # to convert into scale accepted by openpyxl
  335. column_width /= 10
  336. column_widths.append(column_width)
  337. # build table from result
  338. for row_idx, row in enumerate(data.result):
  339. # only pick up rows that are visible in the report
  340. if ignore_visible_idx or row_idx in visible_idx:
  341. row_data = []
  342. if isinstance(row, dict):
  343. for col_idx, column in enumerate(data.columns):
  344. if column.get("hidden"):
  345. continue
  346. label = column.get("label")
  347. fieldname = column.get("fieldname")
  348. cell_value = row.get(fieldname, row.get(label, ""))
  349. if cint(include_indentation) and "indent" in row and col_idx == 0:
  350. cell_value = (" " * cint(row["indent"])) + cstr(cell_value)
  351. row_data.append(cell_value)
  352. elif row:
  353. row_data = row
  354. result.append(row_data)
  355. return result, column_widths
  356. def add_total_row(result, columns, meta=None, report_settings=None):
  357. total_row = [""] * len(columns)
  358. has_percent = []
  359. is_tree = False
  360. parent_field = ''
  361. if report_settings:
  362. if isinstance(report_settings, (str,)):
  363. report_settings = json.loads(report_settings)
  364. is_tree = report_settings.get('tree')
  365. parent_field = report_settings.get('parent_field')
  366. for i, col in enumerate(columns):
  367. fieldtype, options, fieldname = None, None, None
  368. if isinstance(col, str):
  369. if meta:
  370. # get fieldtype from the meta
  371. field = meta.get_field(col)
  372. if field:
  373. fieldtype = meta.get_field(col).fieldtype
  374. fieldname = meta.get_field(col).fieldname
  375. else:
  376. col = col.split(":")
  377. if len(col) > 1:
  378. if col[1]:
  379. fieldtype = col[1]
  380. if "/" in fieldtype:
  381. fieldtype, options = fieldtype.split("/")
  382. else:
  383. fieldtype = "Data"
  384. else:
  385. fieldtype = col.get("fieldtype")
  386. fieldname = col.get("fieldname")
  387. options = col.get("options")
  388. for row in result:
  389. if i >= len(row):
  390. continue
  391. cell = row.get(fieldname) if isinstance(row, dict) else row[i]
  392. if fieldtype in ["Currency", "Int", "Float", "Percent", "Duration"] and flt(
  393. cell
  394. ):
  395. if not (is_tree and row.get(parent_field)):
  396. total_row[i] = flt(total_row[i]) + flt(cell)
  397. if fieldtype == "Percent" and i not in has_percent:
  398. has_percent.append(i)
  399. if fieldtype == "Time" and cell:
  400. if not total_row[i]:
  401. total_row[i] = timedelta(hours=0, minutes=0, seconds=0)
  402. total_row[i] = total_row[i] + cell
  403. if fieldtype == "Link" and options == "Currency":
  404. total_row[i] = (
  405. result[0].get(fieldname)
  406. if isinstance(result[0], dict)
  407. else result[0][i]
  408. )
  409. for i in has_percent:
  410. total_row[i] = flt(total_row[i]) / len(result)
  411. first_col_fieldtype = None
  412. if isinstance(columns[0], str):
  413. first_col = columns[0].split(":")
  414. if len(first_col) > 1:
  415. first_col_fieldtype = first_col[1].split("/")[0]
  416. else:
  417. first_col_fieldtype = columns[0].get("fieldtype")
  418. if first_col_fieldtype not in ["Currency", "Int", "Float", "Percent", "Date"]:
  419. total_row[0] = _("Total")
  420. result.append(total_row)
  421. return result
  422. @frappe.whitelist()
  423. def get_data_for_custom_field(doctype, field):
  424. if not frappe.has_permission(doctype, "read"):
  425. frappe.throw(_("Not Permitted"), frappe.PermissionError)
  426. value_map = frappe._dict(frappe.get_all(doctype, fields=["name", field], as_list=1))
  427. return value_map
  428. def get_data_for_custom_report(columns):
  429. doc_field_value_map = {}
  430. for column in columns:
  431. if column.get("link_field"):
  432. fieldname = column.get("fieldname")
  433. doctype = column.get("doctype")
  434. doc_field_value_map[(doctype, fieldname)] = get_data_for_custom_field(
  435. doctype, fieldname
  436. )
  437. return doc_field_value_map
  438. @frappe.whitelist()
  439. def save_report(reference_report, report_name, columns):
  440. report_doc = get_report_doc(reference_report)
  441. docname = frappe.db.exists(
  442. "Report",
  443. {
  444. "report_name": report_name,
  445. "is_standard": "No",
  446. "report_type": "Custom Report",
  447. },
  448. )
  449. if docname:
  450. report = frappe.get_doc("Report", docname)
  451. existing_jd = json.loads(report.json)
  452. existing_jd["columns"] = json.loads(columns)
  453. report.update({"json": json.dumps(existing_jd, separators=(',', ':'))})
  454. report.save()
  455. frappe.msgprint(_("Report updated successfully"))
  456. return docname
  457. else:
  458. new_report = frappe.get_doc(
  459. {
  460. "doctype": "Report",
  461. "report_name": report_name,
  462. "json": f'{{"columns":{columns}}}',
  463. "ref_doctype": report_doc.ref_doctype,
  464. "is_standard": "No",
  465. "report_type": "Custom Report",
  466. "reference_report": reference_report,
  467. }
  468. ).insert(ignore_permissions=True)
  469. frappe.msgprint(_("{0} saved successfully").format(new_report.name))
  470. return new_report.name
  471. def get_filtered_data(ref_doctype, columns, data, user):
  472. result = []
  473. linked_doctypes = get_linked_doctypes(columns, data)
  474. match_filters_per_doctype = get_user_match_filters(linked_doctypes, user=user)
  475. shared = frappe.share.get_shared(ref_doctype, user)
  476. columns_dict = get_columns_dict(columns)
  477. role_permissions = get_role_permissions(frappe.get_meta(ref_doctype), user)
  478. if_owner = role_permissions.get("if_owner", {}).get("report")
  479. if match_filters_per_doctype:
  480. for row in data:
  481. # Why linked_doctypes.get(ref_doctype)? because if column is empty, linked_doctypes[ref_doctype] is removed
  482. if (
  483. linked_doctypes.get(ref_doctype)
  484. and shared
  485. and row[linked_doctypes[ref_doctype]] in shared
  486. ):
  487. result.append(row)
  488. elif has_match(
  489. row,
  490. linked_doctypes,
  491. match_filters_per_doctype,
  492. ref_doctype,
  493. if_owner,
  494. columns_dict,
  495. user,
  496. ):
  497. result.append(row)
  498. else:
  499. result = list(data)
  500. return result
  501. def has_match(
  502. row,
  503. linked_doctypes,
  504. doctype_match_filters,
  505. ref_doctype,
  506. if_owner,
  507. columns_dict,
  508. user,
  509. ):
  510. """Returns True if after evaluating permissions for each linked doctype
  511. - There is an owner match for the ref_doctype
  512. - `and` There is a user permission match for all linked doctypes
  513. Returns True if the row is empty
  514. Note:
  515. Each doctype could have multiple conflicting user permission doctypes.
  516. Hence even if one of the sets allows a match, it is true.
  517. This behavior is equivalent to the trickling of user permissions of linked doctypes to the ref doctype.
  518. """
  519. resultant_match = True
  520. if not row:
  521. # allow empty rows :)
  522. return resultant_match
  523. for doctype, filter_list in doctype_match_filters.items():
  524. matched_for_doctype = False
  525. if doctype == ref_doctype and if_owner:
  526. idx = linked_doctypes.get("User")
  527. if (
  528. idx is not None
  529. and row[idx] == user
  530. and columns_dict[idx] == columns_dict.get("owner")
  531. ):
  532. # owner match is true
  533. matched_for_doctype = True
  534. if not matched_for_doctype:
  535. for match_filters in filter_list:
  536. match = True
  537. for dt, idx in linked_doctypes.items():
  538. # case handled above
  539. if dt == "User" and columns_dict[idx] == columns_dict.get("owner"):
  540. continue
  541. cell_value = None
  542. if isinstance(row, dict):
  543. cell_value = row.get(idx)
  544. elif isinstance(row, (list, tuple)):
  545. cell_value = row[idx]
  546. if (
  547. dt in match_filters
  548. and cell_value not in match_filters.get(dt)
  549. and frappe.db.exists(dt, cell_value)
  550. ):
  551. match = False
  552. break
  553. # each doctype could have multiple conflicting user permission doctypes, hence using OR
  554. # so that even if one of the sets allows a match, it is true
  555. matched_for_doctype = matched_for_doctype or match
  556. if matched_for_doctype:
  557. break
  558. # each doctype's user permissions should match the row! hence using AND
  559. resultant_match = resultant_match and matched_for_doctype
  560. if not resultant_match:
  561. break
  562. return resultant_match
  563. def get_linked_doctypes(columns, data):
  564. linked_doctypes = {}
  565. columns_dict = get_columns_dict(columns)
  566. for idx, col in enumerate(columns):
  567. df = columns_dict[idx]
  568. if df.get("fieldtype") == "Link":
  569. if data and isinstance(data[0], (list, tuple)):
  570. linked_doctypes[df["options"]] = idx
  571. else:
  572. # dict
  573. linked_doctypes[df["options"]] = df["fieldname"]
  574. # remove doctype if column is empty
  575. columns_with_value = []
  576. for row in data:
  577. if row:
  578. if len(row) != len(columns_with_value):
  579. if isinstance(row, (list, tuple)):
  580. row = enumerate(row)
  581. elif isinstance(row, dict):
  582. row = row.items()
  583. for col, val in row:
  584. if val and col not in columns_with_value:
  585. columns_with_value.append(col)
  586. items = list(linked_doctypes.items())
  587. for doctype, key in items:
  588. if key not in columns_with_value:
  589. del linked_doctypes[doctype]
  590. return linked_doctypes
  591. def get_columns_dict(columns):
  592. """Returns a dict with column docfield values as dict
  593. The keys for the dict are both idx and fieldname,
  594. so either index or fieldname can be used to search for a column's docfield properties
  595. """
  596. columns_dict = frappe._dict()
  597. for idx, col in enumerate(columns):
  598. col_dict = get_column_as_dict(col)
  599. columns_dict[idx] = col_dict
  600. columns_dict[col_dict["fieldname"]] = col_dict
  601. return columns_dict
  602. def get_column_as_dict(col):
  603. col_dict = frappe._dict()
  604. # string
  605. if isinstance(col, str):
  606. col = col.split(":")
  607. if len(col) > 1:
  608. if "/" in col[1]:
  609. col_dict["fieldtype"], col_dict["options"] = col[1].split("/")
  610. else:
  611. col_dict["fieldtype"] = col[1]
  612. if len(col) == 3:
  613. col_dict["width"] = col[2]
  614. col_dict["label"] = col[0]
  615. col_dict["fieldname"] = frappe.scrub(col[0])
  616. # dict
  617. else:
  618. col_dict.update(col)
  619. if "fieldname" not in col_dict:
  620. col_dict["fieldname"] = frappe.scrub(col_dict["label"])
  621. return col_dict
  622. def get_user_match_filters(doctypes, user):
  623. match_filters = {}
  624. for dt in doctypes:
  625. filter_list = frappe.desk.reportview.build_match_conditions(dt, user, False)
  626. if filter_list:
  627. match_filters[dt] = filter_list
  628. return match_filters