diff --git a/frappe/__init__.py b/frappe/__init__.py index 2d491ca068..8424fd0071 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -1987,6 +1987,7 @@ def get_print( no_letterhead=0, password=None, pdf_options=None, + letterhead=None, ): """Get Print Format for given document. @@ -2005,6 +2006,7 @@ def get_print( local.form_dict.style = style local.form_dict.doc = doc local.form_dict.no_letterhead = no_letterhead + local.form_dict.letterhead = letterhead pdf_options = pdf_options or {} if password: diff --git a/frappe/app.py b/frappe/app.py index fc679aa44e..2fe9991c4c 100644 --- a/frappe/app.py +++ b/frappe/app.py @@ -77,7 +77,7 @@ def application(request: Request): rollback = after_request(rollback) finally: - if request.method in ("POST", "PUT") and frappe.db and rollback: + if request.method in UNSAFE_HTTP_METHODS and frappe.db and rollback: frappe.db.rollback() frappe.rate_limiter.update() @@ -320,12 +320,16 @@ def handle_exception(e): def after_request(rollback): # if HTTP method would change server state, commit if necessary - if frappe.db and ( - frappe.local.flags.commit or frappe.local.request.method in UNSAFE_HTTP_METHODS + if ( + frappe.db + and (frappe.local.flags.commit or frappe.local.request.method in UNSAFE_HTTP_METHODS) + and frappe.db.transaction_writes ): - if frappe.db.transaction_writes: - frappe.db.commit() - rollback = False + frappe.db.commit() + rollback = False + elif frappe.db: + frappe.db.rollback() + rollback = False # update session if getattr(frappe.local, "session_obj", None): diff --git a/frappe/commands/scheduler.py b/frappe/commands/scheduler.py index e481676088..0c8278dcbf 100755 --- a/frappe/commands/scheduler.py +++ b/frappe/commands/scheduler.py @@ -178,15 +178,34 @@ def start_scheduler(): @click.command("worker") -@click.option("--queue", type=str) +@click.option( + "--queue", + type=str, + help="Queue to consume from. Multiple queues can be specified using comma-separated string. If not specified all queues are consumed.", +) @click.option("--quiet", is_flag=True, default=False, help="Hide Log Outputs") @click.option("-u", "--rq-username", default=None, help="Redis ACL user") @click.option("-p", "--rq-password", default=None, help="Redis ACL user password") -def start_worker(queue, quiet=False, rq_username=None, rq_password=None): - """Site is used to find redis credentals.""" +@click.option("--burst", is_flag=True, default=False, help="Run Worker in Burst mode.") +@click.option( + "--strategy", + required=False, + type=click.Choice(["round_robin", "random"]), + help="Dequeuing strategy to use", +) +def start_worker( + queue, quiet=False, rq_username=None, rq_password=None, burst=False, strategy=None +): from frappe.utils.background_jobs import start_worker - start_worker(queue, quiet=quiet, rq_username=rq_username, rq_password=rq_password) + start_worker( + queue, + quiet=quiet, + rq_username=rq_username, + rq_password=rq_password, + burst=burst, + strategy=strategy, + ) @click.command("ready-for-migration") diff --git a/frappe/commands/site.py b/frappe/commands/site.py index 13f642ea76..37b1a36c6c 100644 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -1119,7 +1119,7 @@ def build_search_index(context): @click.command("clear-log-table") -@click.option("--doctype", default="text", type=click.Choice(LOG_DOCTYPES), help="Log DocType") +@click.option("--doctype", required=True, type=click.Choice(LOG_DOCTYPES), help="Log DocType") @click.option("--days", type=int, help="Keep records for days") @click.option("--no-backup", is_flag=True, default=False, help="Do not backup the table") @pass_context diff --git a/frappe/core/doctype/data_export/exporter.py b/frappe/core/doctype/data_export/exporter.py index 611592531d..366245b5e8 100644 --- a/frappe/core/doctype/data_export/exporter.py +++ b/frappe/core/doctype/data_export/exporter.py @@ -432,23 +432,20 @@ class DataExporter: row[_column_start_end.start + i + 1] = value def build_response_as_excel(self): + from frappe.desk.utils import provide_binary_file + from frappe.utils.xlsxutils import make_xlsx + filename = frappe.generate_hash(length=10) with open(filename, "wb") as f: f.write(cstr(self.writer.getvalue()).encode("utf-8")) f = open(filename) reader = csv.reader(f) - - from frappe.utils.xlsxutils import make_xlsx - xlsx_file = make_xlsx(reader, "Data Import Template" if self.template else "Data Export") f.close() os.remove(filename) - # write out response as a xlsx type - frappe.response["filename"] = self.doctype + ".xlsx" - frappe.response["filecontent"] = xlsx_file.getvalue() - frappe.response["type"] = "binary" + provide_binary_file(self.doctype, "xlsx", xlsx_file.getvalue()) def _append_name_column(self, dt=None): self.append_field_column( diff --git a/frappe/core/doctype/data_import/exporter.py b/frappe/core/doctype/data_import/exporter.py index 9c793767e3..1066b8bed9 100644 --- a/frappe/core/doctype/data_import/exporter.py +++ b/frappe/core/doctype/data_import/exporter.py @@ -1,8 +1,6 @@ # Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE -import typing - import frappe from frappe import _ from frappe.model import display_fieldtypes, no_value_fields @@ -241,15 +239,9 @@ class Exporter: def build_response(self): if self.file_type == "CSV": - self.build_csv_response() + build_csv_response(self.get_csv_array_for_export(), _(self.doctype)) elif self.file_type == "Excel": - self.build_xlsx_response() - - def build_csv_response(self): - build_csv_response(self.get_csv_array_for_export(), _(self.doctype)) - - def build_xlsx_response(self): - build_xlsx_response(self.get_csv_array_for_export(), _(self.doctype)) + build_xlsx_response(self.get_csv_array_for_export(), _(self.doctype)) def group_children_data_by_parent(self, children_data: dict[str, list]): return groupby_metric(children_data, key="parent") diff --git a/frappe/core/doctype/doctype/doctype.js b/frappe/core/doctype/doctype/doctype.js index 24c367b115..7324a92359 100644 --- a/frappe/core/doctype/doctype/doctype.js +++ b/frappe/core/doctype/doctype/doctype.js @@ -124,6 +124,7 @@ frappe.ui.form.on("DocField", { let doctypes = frm.doc.fields .filter((df) => df.fieldtype == "Link") .filter((df) => df.options && df.fieldname != row.fieldname) + .sort((a, b) => a.options.localeCompare(b.options)) .map((df) => ({ label: `${df.options} (${df.fieldname})`, value: df.fieldname, @@ -151,6 +152,7 @@ frappe.ui.form.on("DocField", { .get_docfields(link_doctype, null, { fieldtype: ["not in", frappe.model.no_value_type], }) + .sort((a, b) => a.label.localeCompare(b.label)) .map((df) => ({ label: `${df.label} (${df.fieldtype})`, value: df.fieldname, diff --git a/frappe/core/doctype/log_settings/log_settings.py b/frappe/core/doctype/log_settings/log_settings.py index f0f2cdaae8..e1e1d5d176 100644 --- a/frappe/core/doctype/log_settings/log_settings.py +++ b/frappe/core/doctype/log_settings/log_settings.py @@ -18,6 +18,7 @@ DEFAULT_LOGTYPES_RETENTION = { "Scheduled Job Log": 90, "Route History": 90, "Submission Queue": 30, + "Prepared Report": 30, } @@ -155,7 +156,6 @@ LOG_DOCTYPES = [ "Email Queue Recipient", "Error Snapshot", "Error Log", - "Submission Queue", ] diff --git a/frappe/core/doctype/prepared_report/prepared_report.js b/frappe/core/doctype/prepared_report/prepared_report.js index 58f4c1957f..11f9caf8fd 100644 --- a/frappe/core/doctype/prepared_report/prepared_report.js +++ b/frappe/core/doctype/prepared_report/prepared_report.js @@ -2,7 +2,7 @@ // For license information, please see license.txt frappe.ui.form.on("Prepared Report", { - onload: function (frm) { + render_filter_values: function (frm) { var wrapper = $(frm.fields_dict["filter_values"].wrapper).empty(); let filter_table = $(` @@ -16,6 +16,7 @@ frappe.ui.form.on("Prepared Report", {
`); const filters = JSON.parse(frm.doc.filters); + frm.toggle_display(["filter_values"], !$.isEmptyObject(filters)); Object.keys(filters).forEach((key) => { const filter_row = $(` @@ -30,6 +31,12 @@ frappe.ui.form.on("Prepared Report", { refresh: function (frm) { frm.disable_save(); + frm.events.render_filter_values(frm); + + // always keep report_name hidden - we do this as we can't set mandatory and hidden + // property on a docfield at the same time + frm.toggle_display(["report_name"], 0); + if (frm.doc.status == "Completed") { frm.page.set_primary_action(__("Show Report"), () => { frappe.set_route( diff --git a/frappe/core/doctype/prepared_report/prepared_report.json b/frappe/core/doctype/prepared_report/prepared_report.json index cafe323519..d00175b693 100644 --- a/frappe/core/doctype/prepared_report/prepared_report.json +++ b/frappe/core/doctype/prepared_report/prepared_report.json @@ -1,38 +1,31 @@ { "actions": [], - "autoname": "REP.#####", + "autoname": "hash", "creation": "2018-06-25 18:39:11.152960", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", "field_order": [ - "report_name", - "ref_report_doctype", "status", + "report_name", + "queued_by", + "job_id", "column_break_4", - "report_start_time", + "queued_at", "report_end_time", "section_break_7", "error_message", "filters_sb", "filters", - "filter_values", - "columns" + "filter_values" ], "fields": [ { "fieldname": "report_name", "fieldtype": "Data", "label": "Report Name", - "read_only": 1 - }, - { - "fieldname": "ref_report_doctype", - "fieldtype": "Link", - "in_standard_filter": 1, - "label": "Report Type", - "options": "Report", - "read_only": 1 + "read_only": 1, + "reqd": 1 }, { "default": "Queued", @@ -49,16 +42,10 @@ "fieldname": "column_break_4", "fieldtype": "Column Break" }, - { - "fieldname": "report_start_time", - "fieldtype": "Datetime", - "label": "Report Start Time", - "read_only": 1 - }, { "fieldname": "report_end_time", "fieldtype": "Datetime", - "label": "Report End Time", + "label": "Finished At", "read_only": 1 }, { @@ -92,22 +79,35 @@ "label": "Filter Values" }, { - "fieldname": "columns", - "fieldtype": "Code", - "hidden": 1, - "label": "Columns", + "fieldname": "job_id", + "fieldtype": "Link", + "label": "Job ID", "no_copy": 1, - "print_hide": 1, + "options": "RQ Job", + "read_only": 1 + }, + { + "fieldname": "queued_by", + "fieldtype": "Data", + "is_virtual": 1, + "label": "Queued By", + "read_only": 1 + }, + { + "fieldname": "queued_at", + "fieldtype": "Datetime", + "is_virtual": 1, + "label": "Queued At", "read_only": 1 } ], "in_create": 1, "links": [], - "modified": "2022-06-13 06:20:34.496412", + "modified": "2022-11-28 21:29:39.883803", "modified_by": "Administrator", "module": "Core", "name": "Prepared Report", - "naming_rule": "Expression (old style)", + "naming_rule": "Random", "owner": "Administrator", "permissions": [ { @@ -134,7 +134,20 @@ ], "sort_field": "modified", "sort_order": "DESC", - "states": [], - "title_field": "ref_report_doctype", + "states": [ + { + "color": "Blue", + "title": "Queued" + }, + { + "color": "Red", + "title": "Error" + }, + { + "color": "Green", + "title": "Completed" + } + ], + "title_field": "report_name", "track_changes": 1 } \ No newline at end of file diff --git a/frappe/core/doctype/prepared_report/prepared_report.py b/frappe/core/doctype/prepared_report/prepared_report.py index 0b8e25229d..b5036aaf67 100644 --- a/frappe/core/doctype/prepared_report/prepared_report.py +++ b/frappe/core/doctype/prepared_report/prepared_report.py @@ -4,6 +4,8 @@ import json +from rq import get_current_job + import frappe from frappe.desk.form.load import get_attachments from frappe.desk.query_report import generate_report_result @@ -14,19 +16,53 @@ from frappe.utils.background_jobs import enqueue class PreparedReport(Document): + @property + def queued_by(self): + return self.owner + + @property + def queued_at(self): + return self.creation + + @staticmethod + def clear_old_logs(days=30): + prepared_reports_to_delete = frappe.get_all( + "Prepared Report", + filters={"modified": ["<", frappe.utils.add_days(frappe.utils.now(), -days)]}, + ) + + for batch in frappe.utils.create_batch(prepared_reports_to_delete, 100): + enqueue(method=delete_prepared_reports, reports=batch) + def before_insert(self): self.status = "Queued" - self.report_start_time = frappe.utils.now() - def enqueue_report(self): - enqueue(run_background, prepared_report=self.name, timeout=6000) + def after_insert(self): + enqueue( + generate_report, + queue="long", + prepared_report=self.name, + timeout=1500, + enqueue_after_commit=True, + ) + + def get_prepared_data(self, with_file_name=False): + if attachments := get_attachments(self.doctype, self.name): + attachment = attachments[0] + attached_file = frappe.get_doc("File", attachment.name) + + if with_file_name: + return (gzip_decompress(attached_file.get_content()), attachment.file_name) + return gzip_decompress(attached_file.get_content()) -def run_background(prepared_report): +def generate_report(prepared_report): + update_job_id(prepared_report, get_current_job().id) + instance = frappe.get_doc("Prepared Report", prepared_report) - report = frappe.get_doc("Report", instance.ref_report_doctype) + report = frappe.get_doc("Report", instance.report_name) - add_data_to_monitor(report=instance.ref_report_doctype) + add_data_to_monitor(report=instance.report_name) try: report.custom_columns = [] @@ -41,19 +77,15 @@ def run_background(prepared_report): report.custom_columns = data["columns"] result = generate_report_result(report=report, filters=instance.filters, user=instance.owner) - create_json_gz_file(result["result"], "Prepared Report", instance.name) + create_json_gz_file(result, instance.doctype, instance.name) instance.status = "Completed" - instance.columns = json.dumps(result["columns"]) - instance.report_end_time = frappe.utils.now() - instance.save(ignore_permissions=True) - except Exception: - report.log_error("Prepared report failed") - instance = frappe.get_doc("Prepared Report", prepared_report) instance.status = "Error" instance.error_message = frappe.get_traceback() - instance.save(ignore_permissions=True) + + instance.report_end_time = frappe.utils.now() + instance.save(ignore_permissions=True) frappe.publish_realtime( "report_generated", @@ -62,6 +94,27 @@ def run_background(prepared_report): ) +def update_job_id(prepared_report, job_id): + frappe.db.set_value("Prepared Report", prepared_report, "job_id", job_id, update_modified=False) + frappe.db.commit() + + +@frappe.whitelist() +def make_prepared_report(report_name, filters=None): + """run reports in background""" + prepared_report = frappe.get_doc( + { + "doctype": "Prepared Report", + "report_name": report_name, + # This looks like an insanity but, without this it'd be very hard to find Prepared Reports matching given condition + # We're ensuring that spacing is consistent. e.g. JS seems to put no spaces after ":", Python on the other hand does. + "filters": json.dumps(json.loads(filters)), + } + ).insert(ignore_permissions=True) + + return {"name": prepared_report.name} + + @frappe.whitelist() def get_reports_in_queued_state(report_name, filters): reports = frappe.get_all( @@ -70,27 +123,22 @@ def get_reports_in_queued_state(report_name, filters): "report_name": report_name, "filters": json.dumps(json.loads(filters)), "status": "Queued", + "owner": frappe.session.user, }, ) return reports -def delete_expired_prepared_reports(): - system_settings = frappe.get_single("System Settings") - enable_auto_deletion = system_settings.enable_prepared_report_auto_deletion - if enable_auto_deletion: - expiry_period = system_settings.prepared_report_expiry_period - prepared_reports_to_delete = frappe.get_all( - "Prepared Report", - filters={"creation": ["<", frappe.utils.add_days(frappe.utils.now(), -expiry_period)]}, - ) - - batches = frappe.utils.create_batch(prepared_reports_to_delete, 100) - for batch in batches: - args = { - "reports": batch, - } - enqueue(method=delete_prepared_reports, job_name="delete_prepared_reports", **args) +def get_completed_prepared_report(filters, user, report_name): + return frappe.db.get_value( + "Prepared Report", + filters={ + "status": "Completed", + "filters": json.dumps(filters), + "owner": user, + "report_name": report_name, + }, + ) @frappe.whitelist() @@ -127,10 +175,13 @@ def create_json_gz_file(data, dt, dn): @frappe.whitelist() def download_attachment(dn): - attachment = get_attachments("Prepared Report", dn)[0] - frappe.local.response.filename = attachment.file_name[:-2] - attached_file = frappe.get_doc("File", attachment.name) - frappe.local.response.filecontent = gzip_decompress(attached_file.get_content()) + pr = frappe.get_doc("Prepared Report", dn) + if not pr.has_permission("read"): + frappe.throw(frappe._("Cannot Download Report due to insufficient permissions")) + + data, file_name = pr.get_prepared_data(with_file_name=True) + frappe.local.response.filename = file_name[:-3] + frappe.local.response.filecontent = data frappe.local.response.type = "binary" @@ -149,9 +200,7 @@ def get_permission_query_condition(user): reports = [frappe.db.escape(report) for report in user.get_all_reports().keys()] - return """`tabPrepared Report`.ref_report_doctype in ({reports})""".format( - reports=",".join(reports) - ) + return """`tabPrepared Report`.report_name in ({reports})""".format(reports=",".join(reports)) def has_permission(doc, user): @@ -167,4 +216,4 @@ def has_permission(doc, user): if "System Manager" in user.roles: return True - return doc.ref_report_doctype in user.get_all_reports().keys() + return doc.report_name in user.get_all_reports().keys() diff --git a/frappe/core/doctype/prepared_report/prepared_report_list.js b/frappe/core/doctype/prepared_report/prepared_report_list.js deleted file mode 100644 index 1414dd7580..0000000000 --- a/frappe/core/doctype/prepared_report/prepared_report_list.js +++ /dev/null @@ -1,12 +0,0 @@ -frappe.listview_settings["Prepared Report"] = { - add_fields: ["status"], - get_indicator: function (doc) { - if (doc.status === "Completed") { - return [__("Completed"), "green", "status,=,Completed"]; - } else if (doc.status === "Error") { - return [__("Error"), "red", "status,=,Error"]; - } else if (doc.status === "Queued") { - return [__("Queued"), "orange", "status,=,Queued"]; - } - }, -}; diff --git a/frappe/core/doctype/prepared_report/test_prepared_report.py b/frappe/core/doctype/prepared_report/test_prepared_report.py index cd96e8a18f..a864ea73f8 100644 --- a/frappe/core/doctype/prepared_report/test_prepared_report.py +++ b/frappe/core/doctype/prepared_report/test_prepared_report.py @@ -1,28 +1,53 @@ # Copyright (c) 2018, Frappe Technologies and Contributors # License: MIT. See LICENSE import json +import time import frappe +from frappe.desk.query_report import generate_report_result, get_report_doc from frappe.tests.utils import FrappeTestCase class TestPreparedReport(FrappeTestCase): - def setUp(self): - self.report = frappe.get_doc({"doctype": "Report", "name": "Permitted Documents For User"}) - self.filters = {"user": "Administrator", "doctype": "Role"} - self.prepared_report_doc = frappe.get_doc( + @classmethod + def tearDownClass(cls): + for r in frappe.get_all("Prepared Report", pluck="name"): + frappe.delete_doc("Prepared Report", r, force=True, delete_permanently=True) + + frappe.db.commit() + + def create_prepared_report(self, commit=False): + doc = frappe.get_doc( { "doctype": "Prepared Report", - "report_name": self.report.name, - "filters": json.dumps(self.filters), - "ref_report_doctype": self.report.name, + "report_name": "Database Storage Usage By Tables", } ).insert() - def tearDown(self): - frappe.set_user("Administrator") - self.prepared_report_doc.delete() + if commit: + frappe.db.commit() - def test_for_creation(self): - self.assertTrue("QUEUED" == self.prepared_report_doc.status.upper()) - self.assertTrue(self.prepared_report_doc.report_start_time) + return doc + + def test_queueing(self): + doc_ = self.create_prepared_report() + self.assertEqual("Queued", doc_.status) + self.assertTrue(doc_.queued_at) + + frappe.db.commit() + time.sleep(5) + + doc_ = frappe.get_last_doc("Prepared Report") + self.assertEqual("Completed", doc_.status) + self.assertTrue(doc_.job_id) + self.assertTrue(doc_.report_end_time) + + def test_prepared_data(self): + doc_ = self.create_prepared_report(commit=True) + time.sleep(5) + + prepared_data = json.loads(doc_.get_prepared_data().decode("utf-8")) + generated_data = generate_report_result(get_report_doc("Database Storage Usage By Tables")) + self.assertEqual(len(prepared_data["columns"]), len(generated_data["columns"])) + self.assertEqual(len(prepared_data["result"]), len(generated_data["result"])) + self.assertEqual(len(prepared_data), len(generated_data)) diff --git a/frappe/core/doctype/report/report.json b/frappe/core/doctype/report/report.json index 4b98f1a3eb..37dce73dda 100644 --- a/frappe/core/doctype/report/report.json +++ b/frappe/core/doctype/report/report.json @@ -16,7 +16,6 @@ "letter_head", "add_total_row", "disabled", - "disable_prepared_report", "prepared_report", "filters_section", "filters", @@ -133,19 +132,11 @@ "label": "Roles", "options": "Has Role" }, - { - "default": "0", - "fieldname": "disable_prepared_report", - "fieldtype": "Check", - "label": "Disable Prepared Report" - }, { "default": "0", "fieldname": "prepared_report", "fieldtype": "Check", - "hidden": 1, - "label": "Prepared Report", - "read_only": 1 + "label": "Prepared Report" }, { "depends_on": "eval:(doc.report_type===\"Script Report\" \n|| doc.report_type==\"Query Report\") \n&& doc.is_standard===\"No\"", @@ -191,7 +182,7 @@ "idx": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2022-09-15 13:37:24.531848", + "modified": "2022-11-20 14:56:36.578412", "modified_by": "Administrator", "module": "Core", "name": "Report", diff --git a/frappe/core/doctype/report/report.py b/frappe/core/doctype/report/report.py index 7fe3cadf9c..ae862184b6 100644 --- a/frappe/core/doctype/report/report.py +++ b/frappe/core/doctype/report/report.py @@ -57,17 +57,6 @@ class Report(Document): ): frappe.throw(_("You are not allowed to delete Standard Report")) delete_custom_role("report", self.name) - self.delete_prepared_reports() - - def delete_prepared_reports(self): - prepared_reports = frappe.get_all( - "Prepared Report", filters={"ref_report_doctype": self.name}, pluck="name" - ) - - for report in prepared_reports: - frappe.delete_doc( - "Prepared Report", report, ignore_missing=True, force=True, delete_permanently=True - ) def get_columns(self): return [d.as_dict(no_default_fields=True, no_child_table_fields=True) for d in self.columns] @@ -344,9 +333,8 @@ class Report(Document): self.db_set("disabled", cint(disable)) -@frappe.whitelist() -def is_prepared_report_disabled(report): - return frappe.db.get_value("Report", report, "disable_prepared_report") or 0 +def is_prepared_report_enabled(report): + return cint(frappe.db.get_value("Report", report, "prepared_report")) or 0 def get_report_module_dotted_path(module, report_name): diff --git a/frappe/core/doctype/role_permission_for_page_and_report/role_permission_for_page_and_report.json b/frappe/core/doctype/role_permission_for_page_and_report/role_permission_for_page_and_report.json index 09982cf639..52ecc5d38f 100644 --- a/frappe/core/doctype/role_permission_for_page_and_report/role_permission_for_page_and_report.json +++ b/frappe/core/doctype/role_permission_for_page_and_report/role_permission_for_page_and_report.json @@ -10,7 +10,7 @@ "page", "report", "column_break_4", - "disable_prepared_report", + "enable_prepared_report", "roles_permission", "roles_html", "roles" @@ -42,13 +42,6 @@ "fieldname": "column_break_4", "fieldtype": "Column Break" }, - { - "default": "0", - "depends_on": "report", - "fieldname": "disable_prepared_report", - "fieldtype": "Check", - "label": "Disable Prepared Report" - }, { "fieldname": "roles_permission", "fieldtype": "Section Break", @@ -66,12 +59,19 @@ "label": "Roles", "options": "Has Role", "read_only": 1 + }, + { + "default": "0", + "depends_on": "report", + "fieldname": "enable_prepared_report", + "fieldtype": "Check", + "label": "Enable Prepared Report" } ], "hide_toolbar": 1, "issingle": 1, "links": [], - "modified": "2022-08-03 12:20:54.079809", + "modified": "2022-11-23 12:39:05.750386", "modified_by": "Administrator", "module": "Core", "name": "Role Permission for Page and Report", diff --git a/frappe/core/doctype/role_permission_for_page_and_report/role_permission_for_page_and_report.py b/frappe/core/doctype/role_permission_for_page_and_report/role_permission_for_page_and_report.py index bd61995ba3..9a3511184d 100644 --- a/frappe/core/doctype/role_permission_for_page_and_report/role_permission_for_page_and_report.py +++ b/frappe/core/doctype/role_permission_for_page_and_report/role_permission_for_page_and_report.py @@ -2,8 +2,9 @@ # License: MIT. See LICENSE import frappe -from frappe.core.doctype.report.report import is_prepared_report_disabled +from frappe.core.doctype.report.report import is_prepared_report_enabled from frappe.model.document import Document +from frappe.utils import cint class RolePermissionforPageandReport(Document): @@ -27,7 +28,7 @@ class RolePermissionforPageandReport(Document): def check_prepared_report_disabled(self): if self.report: - self.disable_prepared_report = is_prepared_report_disabled(self.report) + self.enable_prepared_report = is_prepared_report_enabled(self.report) def get_standard_roles(self): doctype = self.set_role_for @@ -67,9 +68,9 @@ class RolePermissionforPageandReport(Document): if self.report: # intentionally written update query in frappe.db.sql instead of frappe.db.set_value frappe.db.sql( - """ update `tabReport` set disable_prepared_report = %s + """update `tabReport` set prepared_report = %s where name = %s""", - (self.disable_prepared_report, self.report), + (self.enable_prepared_report, self.report), ) def get_args(self, row=None): diff --git a/frappe/core/doctype/role_permission_for_page_and_report/test_role_permission_for_page_and_report.py b/frappe/core/doctype/role_permission_for_page_and_report/test_role_permission_for_page_and_report.py new file mode 100644 index 0000000000..b05da325e6 --- /dev/null +++ b/frappe/core/doctype/role_permission_for_page_and_report/test_role_permission_for_page_and_report.py @@ -0,0 +1,9 @@ +# Copyright (c) 2022, Frappe Technologies and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestRolePermissionforPageandReport(FrappeTestCase): + pass diff --git a/frappe/core/doctype/rq_job/test_rq_job.py b/frappe/core/doctype/rq_job/test_rq_job.py index 460aa08941..654cdcd21f 100644 --- a/frappe/core/doctype/rq_job/test_rq_job.py +++ b/frappe/core/doctype/rq_job/test_rq_job.py @@ -10,6 +10,7 @@ from rq.job import Job import frappe from frappe.core.doctype.rq_job.rq_job import RQJob, remove_failed_jobs, stop_job from frappe.tests.utils import FrappeTestCase, timeout +from frappe.utils import cstr, execute_in_shell from frappe.utils.background_jobs import is_job_queued @@ -92,6 +93,15 @@ class TestRQJob(FrappeTestCase): self.check_status(actual_job, "finished") self.assertFalse(is_job_queued(job_name)) + @timeout(20) + def test_multi_queue_burst_consumption(self): + for _ in range(3): + for q in ["default", "short"]: + frappe.enqueue(self.BG_JOB, sleep=1, queue=q) + + _, stderr = execute_in_shell("bench worker --queue short,default --burst", check_exit_code=True) + self.assertIn("quitting", cstr(stderr)) + def test_func(fail=False, sleep=0): if fail: diff --git a/frappe/core/doctype/rq_worker/rq_worker.json b/frappe/core/doctype/rq_worker/rq_worker.json index d9a5a23f67..18441377c9 100644 --- a/frappe/core/doctype/rq_worker/rq_worker.json +++ b/frappe/core/doctype/rq_worker/rq_worker.json @@ -76,13 +76,13 @@ { "fieldname": "queue", "fieldtype": "Data", - "label": "Queue" + "label": "Queue(s)" }, { "fieldname": "queue_type", "fieldtype": "Select", "in_list_view": 1, - "label": "Queue Type", + "label": "Queue Type(s)", "options": "default\nlong\nshort" }, { @@ -113,7 +113,7 @@ "in_create": 1, "is_virtual": 1, "links": [], - "modified": "2022-11-14 15:35:32.786012", + "modified": "2022-11-24 14:50:48.511706", "modified_by": "Administrator", "module": "Core", "name": "RQ Worker", diff --git a/frappe/core/doctype/rq_worker/rq_worker.py b/frappe/core/doctype/rq_worker/rq_worker.py index 3de0c8f7fc..1d24001fc3 100644 --- a/frappe/core/doctype/rq_worker/rq_worker.py +++ b/frappe/core/doctype/rq_worker/rq_worker.py @@ -16,8 +16,10 @@ class RQWorker(Document): def load_from_db(self): all_workers = get_workers() - worker = [w for w in all_workers if w.pid == cint(self.name)][0] - d = serialize_worker(worker) + workers = [w for w in all_workers if w.pid == cint(self.name)] + if not workers: + raise frappe.DoesNotExistError + d = serialize_worker(workers[0]) super(Document, self).__init__(d) @@ -51,12 +53,15 @@ class RQWorker(Document): def serialize_worker(worker: Worker) -> frappe._dict: - queue = ", ".join(worker.queue_names()) + queue_names = worker.queue_names() + + queue = ", ".join(queue_names) + queue_types = ",".join(q.rsplit(":", 1)[1] for q in queue_names) return frappe._dict( name=worker.pid, queue=queue, - queue_type=queue.rsplit(":", 1)[1], + queue_type=queue_types, worker_name=worker.name, status=worker.get_state(), pid=worker.pid, diff --git a/frappe/core/doctype/rq_worker/test_rq_worker.py b/frappe/core/doctype/rq_worker/test_rq_worker.py index 5a43270681..f07338d630 100644 --- a/frappe/core/doctype/rq_worker/test_rq_worker.py +++ b/frappe/core/doctype/rq_worker/test_rq_worker.py @@ -10,7 +10,7 @@ class TestRQWorker(FrappeTestCase): def test_get_worker_list(self): workers = RQWorker.get_list({}) self.assertGreaterEqual(len(workers), 1) - self.assertTrue(any(w.queue_type == "short" for w in workers)) + self.assertTrue(any("short" in w.queue_type for w in workers)) def test_worker_serialization(self): workers = RQWorker.get_list({}) diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json index 5fd5ef8163..57e2b1a1be 100644 --- a/frappe/core/doctype/system_settings/system_settings.json +++ b/frappe/core/doctype/system_settings/system_settings.json @@ -70,9 +70,6 @@ "hide_footer_in_auto_email_reports", "attach_view_link", "prepared_report_section", - "enable_prepared_report_auto_deletion", - "prepared_report_expiry_period", - "column_break_64", "max_auto_email_report_per_user", "system_updates_section", "disable_system_update_notification", @@ -427,25 +424,11 @@ "label": "Send document Web View link in email" }, { - "default": "30", - "depends_on": "enable_prepared_report_auto_deletion", - "description": "System will auto-delete Prepared Reports permanently after these many days since creation", - "fieldname": "prepared_report_expiry_period", - "fieldtype": "Int", - "label": "Prepared Report Expiry Period (Days)" - }, - { - "default": "1", - "fieldname": "enable_prepared_report_auto_deletion", - "fieldtype": "Check", - "label": "Enable Auto-deletion of Prepared Reports" - }, - { - "collapsible": 1, - "fieldname": "prepared_report_section", - "fieldtype": "Section Break", - "label": "Reports" - }, + "collapsible": 1, + "fieldname": "prepared_report_section", + "fieldtype": "Section Break", + "label": "Reports" + }, { "default": "Frappe", "description": "The application name will be used in the Login page.", @@ -498,10 +481,6 @@ "fieldtype": "Check", "label": "Allow Older Web View Links (Insecure)" }, - { - "fieldname": "column_break_64", - "fieldtype": "Column Break" - }, { "default": "20", "fieldname": "max_auto_email_report_per_user", @@ -538,7 +517,7 @@ "icon": "fa fa-cog", "issingle": 1, "links": [], - "modified": "2022-10-30 12:02:46.639170", + "modified": "2022-11-20 17:57:05.099512", "modified_by": "Administrator", "module": "Core", "name": "System Settings", diff --git a/frappe/database/database.py b/frappe/database/database.py index dfcc9dfe58..5b775ccc10 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -2,6 +2,7 @@ # License: MIT. See LICENSE import datetime +import itertools import json import random import re @@ -9,6 +10,7 @@ import string import traceback from contextlib import contextmanager, suppress from time import time +from typing import Any, Iterable, Sequence from pypika.dialects import MySQLQueryBuilder, PostgreSQLQueryBuilder from pypika.terms import Criterion, NullValue @@ -1204,28 +1206,36 @@ class Database: frappe.flags.touched_tables = set() frappe.flags.touched_tables.update(tables) - def bulk_insert(self, doctype, fields, values, ignore_duplicates=False, *, chunk_size=10_000): + def bulk_insert( + self, + doctype: str, + fields: list[str], + values: Iterable[Sequence[Any]], + ignore_duplicates=False, + *, + chunk_size=10_000, + ): """ Insert multiple records at a time :param doctype: Doctype name :param fields: list of fields - :params values: list of list of values + :params values: iterable of values """ - values = list(values) table = frappe.qb.DocType(doctype) - for start_index in range(0, len(values), chunk_size): - query = frappe.qb.into(table) - if ignore_duplicates: - # Pypika does not have same api for ignoring duplicates - if self.db_type == "mariadb": - query = query.ignore() - elif self.db_type == "postgres": - query = query.on_conflict().do_nothing() + query = frappe.qb.into(table).columns(fields) - values_to_insert = values[start_index : start_index + chunk_size] - query.columns(fields).insert(*values_to_insert).run() + if ignore_duplicates: + # Pypika does not have same api for ignoring duplicates + if frappe.conf.db_type == "mariadb": + query = query.ignore() + elif frappe.conf.db_type == "postgres": + query = query.on_conflict().do_nothing() + + value_iterator = iter(values) + while value_chunk := tuple(itertools.islice(value_iterator, chunk_size)): + query.insert(*value_chunk).run() def create_sequence(self, *args, **kwargs): from frappe.database.sequence import create_sequence diff --git a/frappe/database/mariadb/database.py b/frappe/database/mariadb/database.py index 322c355357..d8c806ffcf 100644 --- a/frappe/database/mariadb/database.py +++ b/frappe/database/mariadb/database.py @@ -119,7 +119,7 @@ class MariaDBConnectionUtil: "use_unicode": True, } - if self.user != "root": + if self.user not in (frappe.flags.root_login, "root"): conn_settings["database"] = self.user if self.port: diff --git a/frappe/desk/form/linked_with.py b/frappe/desk/form/linked_with.py index cb2b5508ce..5bb07aefe5 100644 --- a/frappe/desk/form/linked_with.py +++ b/frappe/desk/form/linked_with.py @@ -403,7 +403,6 @@ def get_exempted_doctypes(): return auto_cancel_exempt_doctypes -@frappe.whitelist() def get_linked_docs(doctype: str, name: str, linkinfo: dict | None = None) -> dict[str, list]: if isinstance(linkinfo, str): # additional fields are added in linkinfo diff --git a/frappe/desk/query_report.py b/frappe/desk/query_report.py index d0bc63f858..a1e8a9368f 100644 --- a/frappe/desk/query_report.py +++ b/frappe/desk/query_report.py @@ -10,19 +10,12 @@ import frappe import frappe.desk.reportview from frappe import _ from frappe.core.utils import ljust_list +from frappe.desk.reportview import clean_params, parse_json from frappe.model.utils import render_include from frappe.modules import get_module_path, scrub from frappe.monitor import add_data_to_monitor from frappe.permissions import get_role_permissions -from frappe.utils import ( - cint, - cstr, - flt, - format_duration, - get_html_format, - get_url_to_form, - gzip_decompress, -) +from frappe.utils import cint, cstr, flt, format_duration, get_html_format def get_report_doc(report_name): @@ -144,35 +137,6 @@ def normalize_result(result, columns): return data -@frappe.whitelist() -def background_enqueue_run(report_name, filters=None, user=None): - """run reports in background""" - if not user: - user = frappe.session.user - report = get_report_doc(report_name) - track_instance = frappe.get_doc( - { - "doctype": "Prepared Report", - "report_name": report_name, - # This looks like an insanity but, without this it'd be very hard to find Prepared Reports matching given condition - # We're ensuring that spacing is consistent. e.g. JS seems to put no spaces after ":", Python on the other hand does. - "filters": json.dumps(json.loads(filters)), - "ref_report_doctype": report_name, - "report_type": report.report_type, - "query": report.query, - "module": report.module, - } - ) - track_instance.insert(ignore_permissions=True) - frappe.db.commit() - track_instance.enqueue_report() - - return { - "name": track_instance.name, - "redirect_url": get_url_to_form("Prepared Report", track_instance.name), - } - - @frappe.whitelist() def get_script(report_name): report = get_report_doc(report_name) @@ -230,18 +194,12 @@ def run( result = None - if ( - report.prepared_report - and not report.disable_prepared_report - and not ignore_prepared_report - and not custom_columns - ): + if report.prepared_report and not ignore_prepared_report and not custom_columns: if filters: if isinstance(filters, str): filters = json.loads(filters) - dn = filters.get("prepared_report_name") - filters.pop("prepared_report_name", None) + dn = filters.pop("prepared_report_name", None) else: dn = "" result = get_prepared_report_result(report, filters, dn, user) @@ -271,103 +229,88 @@ def add_custom_column_data(custom_columns, result): def get_prepared_report_result(report, filters, dn="", user=None): - latest_report_data = {} - doc = None - if dn: - # Get specified dn - doc = frappe.get_doc("Prepared Report", dn) - else: - # Only look for completed prepared reports with given filters. - doc_list = frappe.get_all( - "Prepared Report", - filters={ - "status": "Completed", - "filters": json.dumps(filters), - "owner": user, - "report_name": report.get("custom_report") or report.get("report_name"), - }, - order_by="creation desc", + from frappe.core.doctype.prepared_report.prepared_report import get_completed_prepared_report + + def get_report_data(doc, data): + # backwards compatibility - prepared report used to have a columns field, + # we now directly fetch it from the result file + if doc.get("columns") or isinstance(data, list): + columns = (doc.get("columns") and json.loads(doc.columns)) or data[0] + data = {"result": data} + else: + columns = data.get("columns") + + for column in columns: + if isinstance(column, dict) and column.get("label"): + column["label"] = _(column["label"]) + + return data | {"columns": columns} + + report_data = {} + if not dn: + dn = get_completed_prepared_report( + filters, user, report.get("custom_report") or report.get("report_name") ) - if doc_list: - # Get latest - doc = frappe.get_doc("Prepared Report", doc_list[0]) - + doc = frappe.get_doc("Prepared Report", dn) if dn else None if doc: try: - # Prepared Report data is stored in a GZip compressed JSON file - attached_file_name = frappe.db.get_value( - "File", - {"attached_to_doctype": doc.doctype, "attached_to_name": doc.name}, - "name", - ) - attached_file = frappe.get_doc("File", attached_file_name) - compressed_content = attached_file.get_content() - uncompressed_content = gzip_decompress(compressed_content) - data = json.loads(uncompressed_content.decode("utf-8")) - if data: - columns = json.loads(doc.columns) if doc.columns else data[0] - - for column in columns: - if isinstance(column, dict) and column.get("label"): - column["label"] = _(column["label"]) - - latest_report_data = {"columns": columns, "result": data} + if data := json.loads(doc.get_prepared_data().decode("utf-8")): + report_data = get_report_data(doc, data) except Exception: - doc.log_error("Prepared report failed") - frappe.delete_doc("Prepared Report", doc.name) - frappe.db.commit() + doc.log_error("Prepared report render failed") + frappe.msgprint(_("Prepared report render failed")) doc = None - latest_report_data.update({"prepared_report": True, "doc": doc}) - - return latest_report_data + return report_data | {"prepared_report": True, "doc": doc} @frappe.whitelist() def export_query(): """export from query reports""" - data = frappe._dict(frappe.local.form_dict) - data.pop("cmd", None) - data.pop("csrf_token", None) + from frappe.desk.utils import get_csv_bytes, pop_csv_params, provide_binary_file - if isinstance(data.get("filters"), str): - filters = json.loads(data["filters"]) + form_params = frappe._dict(frappe.local.form_dict) + csv_params = pop_csv_params(form_params) + clean_params(form_params) + parse_json(form_params) - if data.get("report_name"): - report_name = data["report_name"] - frappe.permissions.can_export( - frappe.get_cached_value("Report", report_name, "ref_doctype"), - raise_exception=True, - ) + report_name = form_params.report_name + frappe.permissions.can_export( + frappe.get_cached_value("Report", report_name, "ref_doctype"), + raise_exception=True, + ) - file_format_type = data.get("file_format_type") - custom_columns = frappe.parse_json(data.get("custom_columns", "[]")) - include_indentation = data.get("include_indentation") - visible_idx = data.get("visible_idx") + file_format_type = form_params.file_format_type + custom_columns = frappe.parse_json(form_params.custom_columns or "[]") + include_indentation = form_params.include_indentation + visible_idx = form_params.visible_idx if isinstance(visible_idx, str): visible_idx = json.loads(visible_idx) - if file_format_type == "Excel": - data = run(report_name, filters, custom_columns=custom_columns) - data = frappe._dict(data) - if not data.columns: - frappe.respond_as_web_page( - _("No data to export"), - _("You can try changing the filters of your report."), - ) - return + data = run(report_name, form_params.filters, custom_columns=custom_columns) + data = frappe._dict(data) + if not data.columns: + frappe.respond_as_web_page( + _("No data to export"), + _("You can try changing the filters of your report."), + ) + return + format_duration_fields(data) + xlsx_data, column_widths = build_xlsx_data(data, visible_idx, include_indentation) + + if file_format_type == "CSV": + content = get_csv_bytes(xlsx_data, csv_params) + file_extension = "csv" + elif file_format_type == "Excel": from frappe.utils.xlsxutils import make_xlsx - format_duration_fields(data) - xlsx_data, column_widths = build_xlsx_data(data, visible_idx, include_indentation) - xlsx_file = make_xlsx(xlsx_data, "Query Report", column_widths=column_widths) + file_extension = "xlsx" + content = make_xlsx(xlsx_data, "Query Report", column_widths=column_widths).getvalue() - frappe.response["filename"] = report_name + ".xlsx" - frappe.response["filecontent"] = xlsx_file.getvalue() - frappe.response["type"] = "binary" + provide_binary_file(report_name, file_extension, content) def format_duration_fields(data: frappe._dict) -> None: diff --git a/frappe/desk/reportview.py b/frappe/desk/reportview.py index b24ab21455..5450d77377 100644 --- a/frappe/desk/reportview.py +++ b/frappe/desk/reportview.py @@ -4,7 +4,6 @@ """build query for doclistview and return results""" import json -from io import StringIO import frappe import frappe.permissions @@ -14,7 +13,7 @@ from frappe.model import child_table_fields, default_fields, optional_fields from frappe.model.base_document import get_controller from frappe.model.db_query import DatabaseQuery from frappe.model.utils import is_virtual_doctype -from frappe.utils import add_user_info, cstr, format_duration +from frappe.utils import add_user_info, format_duration @frappe.whitelist() @@ -339,30 +338,21 @@ def delete_report(name): @frappe.read_only() def export_query(): """export from report builder""" - title = frappe.form_dict.title - frappe.form_dict.pop("title", None) + from frappe.desk.utils import get_csv_bytes, pop_csv_params, provide_binary_file form_params = get_form_params() form_params["limit_page_length"] = None form_params["as_list"] = True - doctype = form_params.doctype - add_totals_row = None - file_format_type = form_params["file_format_type"] - title = title or doctype - - del form_params["doctype"] - del form_params["file_format_type"] - - if "add_totals_row" in form_params and form_params["add_totals_row"] == "1": - add_totals_row = 1 - del form_params["add_totals_row"] + doctype = form_params.pop("doctype") + file_format_type = form_params.pop("file_format_type") + title = form_params.pop("title", doctype) + csv_params = pop_csv_params(form_params) + add_totals_row = 1 if form_params.pop("add_totals_row", None) == "1" else None frappe.permissions.can_export(doctype, raise_exception=True) - if "selected_items" in form_params: - si = json.loads(frappe.form_dict.get("selected_items")) - form_params["filters"] = {"name": ("in", si)} - del form_params["selected_items"] + if selection := form_params.pop("selected_items", None): + form_params["filters"] = {"name": ("in", json.loads(selection))} make_access_log( doctype=doctype, @@ -378,38 +368,24 @@ def export_query(): ret = append_totals_row(ret) data = [[_("Sr")] + get_labels(db_query.fields, doctype)] - for i, row in enumerate(ret): - data.append([i + 1] + list(row)) - + data.extend([i + 1] + list(row) for i, row in enumerate(ret)) data = handle_duration_fieldtype_values(doctype, data, db_query.fields) if file_format_type == "CSV": - - # convert to csv - import csv - from frappe.utils.xlsxutils import handle_html - f = StringIO() - writer = csv.writer(f) - for r in data: - # encode only unicode type strings and not int, floats etc. - writer.writerow([handle_html(frappe.as_unicode(v)) if isinstance(v, str) else v for v in r]) - - f.seek(0) - frappe.response["result"] = cstr(f.read()) - frappe.response["type"] = "csv" - frappe.response["doctype"] = title - + file_extension = "csv" + content = get_csv_bytes( + [[handle_html(frappe.as_unicode(v)) if isinstance(v, str) else v for v in r] for r in data], + csv_params, + ) elif file_format_type == "Excel": - from frappe.utils.xlsxutils import make_xlsx - xlsx_file = make_xlsx(data, doctype) + file_extension = "xlsx" + content = make_xlsx(data, doctype).getvalue() - frappe.response["filename"] = title + ".xlsx" - frappe.response["filecontent"] = xlsx_file.getvalue() - frappe.response["type"] = "binary" + provide_binary_file(title, file_extension, content) def append_totals_row(data): @@ -436,16 +412,12 @@ def get_labels(fields, doctype): """get column labels based on column names""" labels = [] for key in fields: - key = key.split(" as ")[0] - - if key.startswith(("count(", "sum(", "avg(")): + try: + parenttype, fieldname = parse_field(key) + except ValueError: continue - if "." in key: - parenttype, fieldname = key.split(".")[0][4:-1], key.split(".")[1].strip("`") - else: - parenttype = doctype - fieldname = fieldname.strip("`") + parenttype = parenttype or doctype if parenttype == doctype and fieldname == "name": label = _("ID", context="Label of name column in report") @@ -464,17 +436,12 @@ def get_labels(fields, doctype): def handle_duration_fieldtype_values(doctype, data, fields): for field in fields: - key = field.split(" as ")[0] - - if key.startswith(("count(", "sum(", "avg(")): + try: + parenttype, fieldname = parse_field(field) + except ValueError: continue - if "." in key: - parenttype, fieldname = key.split(".")[0][4:-1], key.split(".")[1].strip("`") - else: - parenttype = doctype - fieldname = field.strip("`") - + parenttype = parenttype or doctype df = frappe.get_meta(parenttype).get_field(fieldname) if df and df.fieldtype == "Duration": @@ -487,6 +454,19 @@ def handle_duration_fieldtype_values(doctype, data, fields): return data +def parse_field(field: str) -> tuple[str | None, str]: + """Parse a field into parenttype and fieldname.""" + key = field.split(" as ")[0] + + if key.startswith(("count(", "sum(", "avg(")): + raise ValueError + + if "." in key: + return key.split(".")[0][4:-1], key.split(".")[1].strip("`") + + return None, key.strip("`") + + @frappe.whitelist() def delete_items(): """delete selected items""" diff --git a/frappe/desk/utils.py b/frappe/desk/utils.py index 72cba79963..428ed95c02 100644 --- a/frappe/desk/utils.py +++ b/frappe/desk/utils.py @@ -27,3 +27,34 @@ def validate_route_conflict(doctype, name): def slug(name): return name.lower().replace(" ", "-") + + +def pop_csv_params(form_dict): + """Pop csv params from form_dict and return them as a dict.""" + from csv import QUOTE_NONNUMERIC + + from frappe.utils.data import cint, cstr + + return { + "delimiter": cstr(form_dict.pop("csv_delimiter", ","))[0], + "quoting": cint(form_dict.pop("csv_quoting", QUOTE_NONNUMERIC)), + } + + +def get_csv_bytes(data: list[list], csv_params: dict) -> bytes: + """Convert data to csv bytes.""" + from csv import writer + from io import StringIO + + file = StringIO() + csv_writer = writer(file, **csv_params) + csv_writer.writerows(data) + + return file.getvalue().encode("utf-8") + + +def provide_binary_file(filename: str, extension: str, content: bytes) -> None: + """Provide a binary file to the client.""" + frappe.response["type"] = "binary" + frappe.response["filecontent"] = content + frappe.response["filename"] = f"{filename}.{extension}" diff --git a/frappe/email/doctype/email_queue/email_queue.py b/frappe/email/doctype/email_queue/email_queue.py index 3c020eea39..56f7f6f5ea 100644 --- a/frappe/email/doctype/email_queue/email_queue.py +++ b/frappe/email/doctype/email_queue/email_queue.py @@ -26,6 +26,7 @@ from frappe.utils import ( cstr, get_hook_method, get_string_between, + get_url, nowdate, sbool, split_emails, @@ -293,15 +294,11 @@ class SendMailContext: message = self.include_attachments(message) return message - def get_tracker_str(self): - tracker_url_html = '' - - message = "" + def get_tracker_str(self) -> str: if frappe.conf.use_ssl and self.email_account_doc.track_email_status: - message = quopri.encodestring( - tracker_url_html.format(frappe.local.site, self.queue_doc.communication).encode() - ).decode() - return message + tracker_url_html = f'' + return quopri.encodestring(tracker_url_html.encode()).decode() + return "" def get_unsubscribe_str(self, recipient_email: str) -> str: unsubscribe_url = "" diff --git a/frappe/hooks.py b/frappe/hooks.py index 73327f0ab1..ce52048c04 100644 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -220,7 +220,6 @@ scheduler_events = { "frappe.automation.doctype.auto_repeat.auto_repeat.make_auto_repeat_entry", "frappe.automation.doctype.auto_repeat.auto_repeat.set_auto_repeat_as_completed", "frappe.email.doctype.unhandled_email.unhandled_email.remove_old_unhandled_emails", - "frappe.core.doctype.prepared_report.prepared_report.delete_expired_prepared_reports", "frappe.core.doctype.log_settings.log_settings.run_log_clean_up", "frappe.utils.subscription.enable_manage_subscription", ], diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index e689f91ddd..1d156d0d1a 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -384,14 +384,21 @@ class DatabaseQuery: _raise_exception() for field in self.fields: + lower_field = field.lower().strip() + if SUB_QUERY_PATTERN.match(field): - if any(f"({keyword}" in field.lower() for keyword in blacklisted_keywords): - _raise_exception() + if lower_field[0] == "(": + subquery_token = lower_field[1:].lstrip().split(" ", 1)[0] + if subquery_token in blacklisted_keywords: + _raise_exception() - if any(f"{keyword}(" in field.lower() for keyword in blacklisted_functions): - _raise_exception() + function = lower_field.split("(", 1)[0].rstrip() + if function in blacklisted_functions: + frappe.throw( + _("Use of function {0} in field is restricted").format(function), exc=frappe.DataError + ) - if "@" in field.lower(): + if "@" in lower_field: # prevent access to global variables _raise_exception() @@ -407,7 +414,7 @@ class DatabaseQuery: if STRICT_FIELD_PATTERN.match(field): frappe.throw(_("Illegal SQL Query")) - if STRICT_UNION_PATTERN.match(field.lower()): + if STRICT_UNION_PATTERN.match(lower_field): frappe.throw(_("Illegal SQL Query")) def extract_tables(self): @@ -904,12 +911,16 @@ class DatabaseQuery: if self.order_by: args.order_by = f"`tab{self.doctype}`.docstatus asc, {args.order_by}" - def validate_order_by_and_group_by(self, parameters): + def validate_order_by_and_group_by(self, parameters: str): """Check order by, group by so that atleast one column is selected and does not have subquery""" if not parameters: return + blacklisted_sql_functions = { + "sleep", + } _lower = parameters.lower() + if "select" in _lower and "from" in _lower: frappe.throw(_("Cannot use sub-query in order by")) @@ -917,13 +928,20 @@ class DatabaseQuery: frappe.throw(_("Illegal SQL Query")) for field in parameters.split(","): - if "." in field and field.strip().startswith("`tab"): - tbl = field.strip().split(".")[0] + field = field.strip() + function = field.split("(", 1)[0].rstrip().lower() + full_field_name = "." in field and field.startswith("`tab") + + if full_field_name: + tbl = field.split(".", 1)[0] if tbl not in self.tables: if tbl.startswith("`"): tbl = tbl[4:-1] frappe.throw(_("Please select atleast 1 column from {0} to sort/group").format(tbl)) + if function in blacklisted_sql_functions: + frappe.throw(_("Cannot use {0} in order/group by").format(field)) + def add_limit(self): if self.limit_page_length: return f"limit {self.limit_page_length} offset {self.limit_start}" diff --git a/frappe/model/document.py b/frappe/model/document.py index f5f710a578..c1d4575ae7 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -3,6 +3,7 @@ import hashlib import json import time +from typing import Any, Generator, Iterable from werkzeug.exceptions import NotFound @@ -1593,3 +1594,40 @@ def execute_action(__doctype, __name, __action, **kwargs): doc.add_comment("Comment", _("Action Failed") + "

" + msg) doc.notify_update() + + +def bulk_insert( + doctype: str, + documents: Iterable["Document"], + ignore_duplicates: bool = False, + chunk_size=10_000, +): + """Insert simple Documents objects to database in bulk. + + Warning/Info: + - All documents are inserted without triggering ANY hooks. + - This function assumes you've done the due dilligence and inserts in similar fashion as db_insert + - Documents can be any iterable / generator containing Document objects + """ + + columns = frappe.get_meta(doctype).get_valid_columns() + values = _document_values_generator(documents, columns) + + frappe.db.bulk_insert( + doctype, columns, values, ignore_duplicates=ignore_duplicates, chunk_size=chunk_size + ) + + +def _document_values_generator( + documents: Iterable["Document"], + columns: list[str], +) -> Generator[tuple[Any], None, None]: + for doc in documents: + doc.creation = doc.modified = now() + doc.created_by = doc.modified_by = frappe.session.user + doc_values = doc.get_valid_dict( + convert_dates_to_str=True, + ignore_nulls=True, + ignore_virtual=True, + ) + yield tuple(doc_values.get(col) for col in columns) diff --git a/frappe/model/meta.py b/frappe/model/meta.py index 5a9c2d906d..ce30011d02 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -230,7 +230,7 @@ class Meta(Document): return fields - def get_valid_columns(self): + def get_valid_columns(self) -> list[str]: if not hasattr(self, "_valid_columns"): table_exists = frappe.db.table_exists(self.name) if self.name in self.special_doctypes and table_exists: diff --git a/frappe/modules/utils.py b/frappe/modules/utils.py index 09a91f21fd..0e124ee3aa 100644 --- a/frappe/modules/utils.py +++ b/frappe/modules/utils.py @@ -296,7 +296,7 @@ def make_boilerplate( controller_body = indent( dedent( """ - def db_insert(self): + def db_insert(self, *args, **kwargs): pass def load_from_db(self): diff --git a/frappe/patches.txt b/frappe/patches.txt index e8289a2790..9c298557c3 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -195,6 +195,8 @@ frappe.patches.v14_0.setup_likes_from_feedback frappe.patches.v14_0.update_webforms frappe.patches.v14_0.delete_payment_gateways frappe.patches.v15_0.remove_event_streaming +frappe.patches.v15_0.remove_prepared_report_settings_from_system_settings +frappe.patches.v15_0.copy_disable_prepared_report_to_prepared_report [post_model_sync] frappe.patches.v14_0.drop_data_import_legacy diff --git a/frappe/patches/v15_0/copy_disable_prepared_report_to_prepared_report.py b/frappe/patches/v15_0/copy_disable_prepared_report_to_prepared_report.py new file mode 100644 index 0000000000..f1e4afb9a4 --- /dev/null +++ b/frappe/patches/v15_0/copy_disable_prepared_report_to_prepared_report.py @@ -0,0 +1,6 @@ +import frappe + + +def execute(): + table = frappe.qb.DocType("Report") + frappe.qb.update(table).set(table.prepared_report, 0).where(table.disable_prepared_report == 1) diff --git a/frappe/patches/v15_0/remove_prepared_report_settings_from_system_settings.py b/frappe/patches/v15_0/remove_prepared_report_settings_from_system_settings.py new file mode 100644 index 0000000000..8c0ec4ca70 --- /dev/null +++ b/frappe/patches/v15_0/remove_prepared_report_settings_from_system_settings.py @@ -0,0 +1,15 @@ +import frappe +from frappe.utils import cint + + +def execute(): + expiry_period = ( + cint(frappe.db.get_single_value("System Settings", "prepared_report_expiry_period")) or 30 + ) + frappe.get_single("Log Settings").register_doctype("Prepared Report", expiry_period) + + singles = frappe.qb.DocType("Singles") + frappe.qb.from_(singles).delete().where( + (singles.doctype == "System Settings") + & (singles.field.isin(["enable_prepared_report_auto_deletion", "prepared_report_expiry_period"])) + ).run() diff --git a/frappe/public/js/frappe/form/controls/base_input.js b/frappe/public/js/frappe/form/controls/base_input.js index 62f1c51f67..43fb4f54dc 100644 --- a/frappe/public/js/frappe/form/controls/base_input.js +++ b/frappe/public/js/frappe/form/controls/base_input.js @@ -83,7 +83,9 @@ frappe.ui.form.ControlInput = class ControlInput extends frappe.ui.form.Control me.value = me.doc[me.df.fieldname] || ""; } - if (me.can_write()) { + let is_fetch_from_read_only = me.df.fetch_from && !me.df.fetch_if_empty; + + if (me.can_write() && !is_fetch_from_read_only) { me.disp_area && $(me.disp_area).toggle(false); $(me.input_area).toggle(true); me.$input && me.$input.prop("disabled", false); @@ -101,6 +103,16 @@ frappe.ui.form.ControlInput = class ControlInput extends frappe.ui.form.Control } } me.$input && me.$input.prop("disabled", true); + + if (is_fetch_from_read_only) { + $(me.disp_area).attr( + "title", + __( + "This value is fetched from {0}'s {1} field", + me.df.fetch_from.split(".") + ) + ); + } } me.set_description(); diff --git a/frappe/public/js/frappe/form/grid.js b/frappe/public/js/frappe/form/grid.js index d0c352d784..3d81744f59 100644 --- a/frappe/public/js/frappe/form/grid.js +++ b/frappe/public/js/frappe/form/grid.js @@ -61,57 +61,56 @@ export default class Grid { make() { let template = ` - - -

-
-
-
-
-
-
-
- Grid Empty State - ${__("No Data")} +
+ + +

+
+
+
+
+
+
+
+ Grid Empty State + ${__("No Data")} +
-
-