diff --git a/frappe/core/doctype/doctype/doctype.json b/frappe/core/doctype/doctype/doctype.json index d42fa62802..6cea37b539 100644 --- a/frappe/core/doctype/doctype/doctype.json +++ b/frappe/core/doctype/doctype/doctype.json @@ -538,7 +538,6 @@ }, { "default": "0", - "depends_on": "eval:!doc.istable", "fieldname": "is_virtual", "fieldtype": "Check", "label": "Is Virtual" diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index e6a25f81b4..b90ae39506 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -1542,9 +1542,9 @@ def validate_fields(meta): return doctype = docfield.options - meta = frappe.get_meta(doctype) + child_doctype_meta = frappe.get_meta(doctype) - if not meta.istable: + if not child_doctype_meta.istable: frappe.throw( _("Option {0} for field {1} is not a child table").format( frappe.bold(doctype), frappe.bold(docfield.fieldname) @@ -1552,6 +1552,15 @@ def validate_fields(meta): title=_("Invalid Option"), ) + if not (meta.is_virtual == child_doctype_meta.is_virtual): + error_msg = " should be virtual." if meta.is_virtual else " cannot be virtual." + frappe.throw( + _("Child Table {0} for field {1}" + error_msg).format( + frappe.bold(doctype), frappe.bold(docfield.fieldname) + ), + title=_("Invalid Option"), + ) + def check_max_height(docfield): if getattr(docfield, "max_height", None) and (docfield.max_height[-2:] not in ("px", "em")): frappe.throw(f"Max for {frappe.bold(docfield.fieldname)} height must be in px, em, rem") diff --git a/frappe/core/doctype/doctype/test_doctype.py b/frappe/core/doctype/doctype/test_doctype.py index b3c2cadc22..54d0e5fb7d 100644 --- a/frappe/core/doctype/doctype/test_doctype.py +++ b/frappe/core/doctype/doctype/test_doctype.py @@ -567,20 +567,10 @@ class TestDocType(FrappeTestCase): "options": "Test Virtual DocType as Child Table", }, ) + self.assertRaises(frappe.exceptions.ValidationError, parent_doc.insert) + parent_doc.is_virtual = 1 parent_doc.insert(ignore_permissions=True) - - # create entry for parent doctype - parent_doc_entry = frappe.get_doc( - {"doctype": "Test Parent Virtual DocType", "some_fieldname": "Test"} - ) - parent_doc_entry.insert(ignore_permissions=True) - - # update the parent doc (should not abort because of any DB query to a virtual child table, as there is none) - parent_doc_entry.some_fieldname = "Test update" - parent_doc_entry.save(ignore_permissions=True) - - # delete the parent doc (should not abort because of any DB query to a virtual child table, as there is none) - parent_doc_entry.delete() + self.assertFalse(frappe.db.table_exists("Test Parent Virtual DocType")) def test_default_fieldname(self): fields = [ diff --git a/frappe/core/page/recorder/__init__.py b/frappe/core/doctype/recorder/__init__.py similarity index 100% rename from frappe/core/page/recorder/__init__.py rename to frappe/core/doctype/recorder/__init__.py diff --git a/frappe/core/doctype/recorder/recorder.js b/frappe/core/doctype/recorder/recorder.js new file mode 100644 index 0000000000..c286abef91 --- /dev/null +++ b/frappe/core/doctype/recorder/recorder.js @@ -0,0 +1,47 @@ +// Copyright (c) 2023, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Recorder Query", "form_render", function (frm, cdt, cdn) { + let row = frappe.get_doc(cdt, cdn); + let stack = JSON.parse(row.stack); + render_html_field(stack, "stack_html", "Stack Trace"); + + let explain_result = JSON.parse(row.explain_result); + render_html_field(explain_result, "sql_explain_html", "SQL Explain"); + + function render_html_field(parsed_json, fieldname, label) { + let html = + "
"; + if (parsed_json.length == 0) { + html += ""; + } else { + html = create_html_table(parsed_json, html); + } + + let field_wrapper = + frm.fields_dict[row.parentfield].grid.grid_rows_by_docname[cdn].grid_form.fields_dict[ + fieldname + ].wrapper; + $(html).appendTo(field_wrapper); + } + + function create_html_table(table_content, html) { + html += + "
"; + html += ""; + Object.keys(table_content[0]).forEach((key) => { + html += ""; + }); + html += ""; + + for (let row in table_content) { + html += ""; + Object.values(table_content[row]).forEach((value) => { + html += ""; + }); + html += ""; + } + html += "
" + key + "
" + value + "
"; + return html; + } +}); diff --git a/frappe/core/doctype/recorder/recorder.json b/frappe/core/doctype/recorder/recorder.json new file mode 100644 index 0000000000..aa0d782811 --- /dev/null +++ b/frappe/core/doctype/recorder/recorder.json @@ -0,0 +1,126 @@ +{ + "actions": [], + "creation": "2023-08-01 12:06:49.630877", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "path", + "number_of_queries", + "time_in_queries", + "method", + "column_break_qo53", + "cmd", + "time", + "duration", + "section_break_1skt", + "request_headers", + "section_break_sgro", + "form_dict", + "section_break_9jhm", + "sql_queries" + ], + "fields": [ + { + "fieldname": "path", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Path" + }, + { + "fieldname": "cmd", + "fieldtype": "Data", + "in_standard_filter": 1, + "label": "CMD" + }, + { + "fieldname": "duration", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Duration" + }, + { + "fieldname": "time", + "fieldtype": "Datetime", + "in_list_view": 1, + "label": "Time" + }, + { + "fieldname": "number_of_queries", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Number of Queries" + }, + { + "fieldname": "time_in_queries", + "fieldtype": "Float", + "label": "Time in Queries" + }, + { + "fieldname": "column_break_qo53", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_1skt", + "fieldtype": "Section Break" + }, + { + "fieldname": "request_headers", + "fieldtype": "Code", + "label": "Request Headers" + }, + { + "fieldname": "section_break_sgro", + "fieldtype": "Section Break" + }, + { + "fieldname": "form_dict", + "fieldtype": "Code", + "label": "Form Dict" + }, + { + "fieldname": "method", + "fieldtype": "Select", + "in_standard_filter": 1, + "label": "Method", + "options": "GET\nPOST\nPUT\nDELETE\nPATCH\nHEAD\nOPTIONS" + }, + { + "fieldname": "sql_queries", + "fieldtype": "Table", + "label": "SQL Queries", + "options": "Recorder Query" + }, + { + "fieldname": "section_break_9jhm", + "fieldtype": "Section Break" + } + ], + "hide_toolbar": 1, + "in_create": 1, + "index_web_pages_for_search": 1, + "is_virtual": 1, + "links": [], + "modified": "2023-08-10 12:01:03.456643", + "modified_by": "Administrator", + "module": "Core", + "name": "Recorder", + "owner": "Administrator", + "permissions": [ + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Administrator", + "share": 1 + } + ], + "sort_field": "duration", + "sort_order": "DESC", + "states": [], + "title_field": "path" +} \ No newline at end of file diff --git a/frappe/core/doctype/recorder/recorder.py b/frappe/core/doctype/recorder/recorder.py new file mode 100644 index 0000000000..4aab095914 --- /dev/null +++ b/frappe/core/doctype/recorder/recorder.py @@ -0,0 +1,119 @@ +# Copyright (c) 2023, Frappe Technologies and contributors +# For license information, please see license.txt + +import frappe +from frappe.model.document import Document +from frappe.recorder import get as get_recorder_data +from frappe.utils import cint, compare, make_filter_dict + + +class Recorder(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.core.doctype.recorder_query.recorder_query import RecorderQuery + from frappe.types import DF + + cmd: DF.Data | None + duration: DF.Float + form_dict: DF.Code | None + method: DF.Literal["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"] + number_of_queries: DF.Int + path: DF.Data | None + request_headers: DF.Code | None + sql_queries: DF.Table[RecorderQuery] + time: DF.Datetime | None + time_in_queries: DF.Float + # end: auto-generated types + + def load_from_db(self): + request_data = get_recorder_data(self.name) + if not request_data: + raise frappe.DoesNotExistError + request = serialize_request(request_data) + super(Document, self).__init__(request) + + @staticmethod + def get_list(args): + start = cint(args.get("start")) or 0 + page_length = cint(args.get("page_length")) or 20 + requests = Recorder.get_filtered_requests(args)[start : start + page_length] + + if order_by_statment := args.get("order_by"): + if "." in order_by_statment: + order_by_statment = order_by_statment.split(".")[1] + + if " " in order_by_statment: + sort_key, sort_order = order_by_statment.split(" ") + else: + sort_key = order_by_statment + sort_order = "desc" + + sort_key = sort_key.replace("`", "") + return sorted(requests, key=lambda r: r.get(sort_key) or 0, reverse=bool(sort_order == "desc")) + + return sorted(requests, key=lambda r: r.duration, reverse=1) + + @staticmethod + def get_count(args): + return len(Recorder.get_filtered_requests(args)) + + @staticmethod + def get_filtered_requests(args): + filters = make_filter_dict(args.get("filters")) + requests = [serialize_request(request) for request in get_recorder_data()] + return [req for req in requests if _evaluate_filters(req, filters)] + + @staticmethod + def get_stats(args): + pass + + @staticmethod + def delete(self): + pass + + def db_insert(self, *args, **kwargs): + pass + + def db_update(self): + pass + + +def serialize_request(request): + request = frappe._dict(request) + if request.get("calls"): + for i in request.calls: + i["stack"] = frappe.as_json(i["stack"]) + i["explain_result"] = frappe.as_json(i["explain_result"]) + request.update( + name=request.get("uuid"), + number_of_queries=request.get("queries"), + time_in_queries=request.get("time_queries"), + request_headers=frappe.as_json(request.get("headers"), indent=4), + form_dict=frappe.as_json(request.get("form_dict"), indent=4), + sql_queries=request.get("calls"), + modified=request.get("time"), + creation=request.get("time"), + ) + + return request + + +def _evaluate_filters(row, filters) -> bool: + for field in filters: + value = row[field] + operand = filters[field][1] + operator = filters[field][0] + + if operator == "like": + operator = "in" # python equivalent. + operand = operand.strip("%") + # Swap because like is "reverse IN" + value, operand = operand, value + + if not compare(value, operator, operand): + return False + return True diff --git a/frappe/core/doctype/recorder/recorder_list.js b/frappe/core/doctype/recorder/recorder_list.js new file mode 100644 index 0000000000..a0eadae260 --- /dev/null +++ b/frappe/core/doctype/recorder/recorder_list.js @@ -0,0 +1,110 @@ +frappe.listview_settings["Recorder"] = { + hide_name_column: true, + + onload(listview) { + listview.page.sidebar.remove(); + if (!has_common(frappe.user_roles, ["Administrator", "System Manager"])) return; + + if (listview.list_view_settings) { + listview.list_view_settings.disable_comment_count = true; + } + + listview.page.add_button(__("Clear"), () => { + frappe.call({ + method: "frappe.recorder.delete", + callback: function () { + listview.refresh(); + }, + }); + }); + + listview.page.add_menu_item(__("Import"), () => { + new frappe.ui.FileUploader({ + folder: this.current_folder, + on_success: (file) => { + if (cur_list.data.length > 0) { + // don't replace existing capture + return; + } + frappe.call({ + method: "frappe.recorder.import_data", + args: { + file: file.file_url, + }, + callback: function () { + listview.refresh(); + }, + }); + }, + }); + }); + + listview.page.add_menu_item(__("Export"), () => { + frappe.call({ + method: "frappe.recorder.export_data", + callback: function (r) { + const data = r.message; + const filename = `${data[0]["uuid"]}..${data[data.length - 1]["uuid"]}.json`; + + const el = document.createElement("a"); + el.setAttribute( + "href", + "data:application/json," + encodeURIComponent(JSON.stringify(data)) + ); + el.setAttribute("download", filename); + el.click(); + }, + }); + }); + + setInterval(() => { + if (listview.list_view_settings.disable_auto_refresh) { + return; + } + if (!listview.enabled) return; + + const route = frappe.get_route() || []; + if (route[0] != "List" || "Recorder" != route[1]) { + return; + } + + listview.refresh(); + }, 5000); + }, + + refresh(listview) { + this.fetch_recorder_status(listview).then(() => this.refresh_controls(listview)); + }, + + refresh_controls(listview) { + this.setup_recorder_controls(listview); + this.update_indicators(listview); + }, + + fetch_recorder_status(listview) { + return frappe.xcall("frappe.recorder.status").then((status) => { + listview.enabled = Boolean(status); + }); + }, + + setup_recorder_controls(listview) { + listview.page.set_primary_action(listview.enabled ? __("Stop") : __("Start"), () => { + frappe.call({ + method: listview.enabled ? "frappe.recorder.stop" : "frappe.recorder.start", + callback: function () { + listview.refresh(); + }, + }); + listview.enabled = !listview.enabled; + this.refresh_controls(listview); + }); + }, + + update_indicators(listview) { + if (listview.enabled) { + listview.page.set_indicator(__("Active"), "green"); + } else { + listview.page.set_indicator(__("Inactive"), "red"); + } + }, +}; diff --git a/frappe/core/doctype/recorder/test_recorder.py b/frappe/core/doctype/recorder/test_recorder.py new file mode 100644 index 0000000000..d0dfc3827b --- /dev/null +++ b/frappe/core/doctype/recorder/test_recorder.py @@ -0,0 +1,74 @@ +# Copyright (c) 2023, Frappe Technologies and Contributors +# See license.txt + +import re + +import frappe +import frappe.recorder +from frappe.core.doctype.recorder.recorder import serialize_request +from frappe.recorder import get as get_recorder_data +from frappe.tests.utils import FrappeTestCase +from frappe.utils import set_request + + +class TestRecorder(FrappeTestCase): + def setUp(self): + self.start_recoder() + + def start_recoder(self): + frappe.recorder.stop() + frappe.recorder.delete() + set_request(path="/api/method/ping") + frappe.recorder.start() + frappe.recorder.record() + + def stop_recorder(self): + frappe.recorder.dump() + + def test_recorder_list(self): + frappe.get_all("User") # trigger one query + self.stop_recorder() + requests = frappe.get_all("Recorder") + self.assertGreaterEqual(len(requests), 1) + request = frappe.get_doc("Recorder", requests[0].name) + self.assertGreaterEqual(len(request.sql_queries), 1) + queries = [sql_query.query for sql_query in request.sql_queries] + match_flag = 0 + for query in queries: + if bool(re.match("^[select.*from `tabUser`]", query, flags=re.IGNORECASE)): + match_flag = 1 + break + self.assertEqual(match_flag, 1) + + def test_recorder_list_filters(self): + user = frappe.qb.DocType("User") + frappe.qb.from_(user).select("name").run() + self.stop_recorder() + + set_request(path="/api/method/abc") + frappe.recorder.start() + frappe.recorder.record() + frappe.get_all("User") + self.stop_recorder() + + requests = frappe.get_list( + "Recorder", filters={"path": ("like", "/api/method/ping"), "number_of_queries": 1} + ) + self.assertGreaterEqual(len(requests), 1) + requests = frappe.get_list("Recorder", filters={"path": ("like", "/api/method/test")}) + self.assertEqual(len(requests), 0) + + requests = frappe.get_list("Recorder", filters={"method": "GET"}) + self.assertGreaterEqual(len(requests), 1) + requests = frappe.get_list("Recorder", filters={"method": "POST"}) + self.assertEqual(len(requests), 0) + + requests = frappe.get_list("Recorder", order_by="path desc") + self.assertEqual(requests[0].path, "/api/method/ping") + + def test_recorder_serialization(self): + frappe.get_all("User") # trigger one query + self.stop_recorder() + requests = frappe.get_all("Recorder") + request_doc = get_recorder_data(requests[0].name) + self.assertIsInstance(serialize_request(request_doc), dict) diff --git a/frappe/core/doctype/recorder_query/__init__.py b/frappe/core/doctype/recorder_query/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/core/doctype/recorder_query/recorder_query.js b/frappe/core/doctype/recorder_query/recorder_query.js new file mode 100644 index 0000000000..6cfeb48944 --- /dev/null +++ b/frappe/core/doctype/recorder_query/recorder_query.js @@ -0,0 +1,8 @@ +// Copyright (c) 2023, Frappe Technologies and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Recorder Query", { +// refresh(frm) { + +// }, +// }); diff --git a/frappe/core/doctype/recorder_query/recorder_query.json b/frappe/core/doctype/recorder_query/recorder_query.json new file mode 100644 index 0000000000..bb4e136178 --- /dev/null +++ b/frappe/core/doctype/recorder_query/recorder_query.json @@ -0,0 +1,109 @@ +{ + "actions": [], + "creation": "2023-08-01 17:04:12.173774", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "index", + "query", + "duration", + "column_break_qmju", + "exact_copies", + "normalized_query", + "normalized_copies", + "section_break_dygy", + "stack_html", + "stack", + "section_break_kvkb", + "sql_explain_html", + "explain_result" + ], + "fields": [ + { + "fieldname": "query", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Query", + "length": 2, + "reqd": 1 + }, + { + "fieldname": "normalized_query", + "fieldtype": "Data", + "label": "Normalized Query" + }, + { + "fieldname": "duration", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Duration", + "reqd": 1 + }, + { + "fieldname": "exact_copies", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Exact Copies", + "reqd": 1 + }, + { + "fieldname": "normalized_copies", + "fieldtype": "Int", + "label": "Normalized Copies" + }, + { + "fieldname": "column_break_qmju", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_dygy", + "fieldtype": "Section Break" + }, + { + "fieldname": "stack", + "fieldtype": "Text", + "hidden": 1, + "print_hide": 1 + }, + { + "fieldname": "stack_html", + "fieldtype": "HTML", + "label": "Stack Trace" + }, + { + "fieldname": "section_break_kvkb", + "fieldtype": "Section Break" + }, + { + "fieldname": "explain_result", + "fieldtype": "Text", + "hidden": 1, + "print_hide": 1 + }, + { + "fieldname": "sql_explain_html", + "fieldtype": "HTML", + "label": "SQL Explain" + }, + { + "fieldname": "index", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Index" + } + ], + "index_web_pages_for_search": 1, + "is_virtual": 1, + "istable": 1, + "links": [], + "modified": "2023-08-07 13:12:23.496002", + "modified_by": "Administrator", + "module": "Core", + "name": "Recorder Query", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/frappe/core/doctype/recorder_query/recorder_query.py b/frappe/core/doctype/recorder_query/recorder_query.py new file mode 100644 index 0000000000..185c927dbe --- /dev/null +++ b/frappe/core/doctype/recorder_query/recorder_query.py @@ -0,0 +1,53 @@ +# Copyright (c) 2023, Frappe Technologies and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class RecorderQuery(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + duration: DF.Float + exact_copies: DF.Int + explain_result: DF.Text | None + index: DF.Int + normalized_copies: DF.Int + normalized_query: DF.Data | None + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + query: DF.Data + stack: DF.Text | None + # end: auto-generated types + pass + + def db_insert(self, *args, **kwargs): + pass + + def load_from_db(self): + pass + + def db_update(self): + pass + + @staticmethod + def get_list(args): + pass + + @staticmethod + def get_count(args): + pass + + @staticmethod + def get_stats(args): + pass + + def delete(self): + pass diff --git a/frappe/core/doctype/recorder_query/test_recorder_query.py b/frappe/core/doctype/recorder_query/test_recorder_query.py new file mode 100644 index 0000000000..a21fdcef08 --- /dev/null +++ b/frappe/core/doctype/recorder_query/test_recorder_query.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe Technologies and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestRecorderQuery(FrappeTestCase): + pass diff --git a/frappe/core/doctype/rq_job/rq_job_list.js b/frappe/core/doctype/rq_job/rq_job_list.js index 7d140d668f..bfdd23377d 100644 --- a/frappe/core/doctype/rq_job/rq_job_list.js +++ b/frappe/core/doctype/rq_job/rq_job_list.js @@ -14,10 +14,6 @@ frappe.listview_settings["RQ Job"] = { __("Actions") ); - if (listview.list_view_settings) { - listview.list_view_settings.disable_sidebar_stats = 1; - } - frappe.xcall("frappe.utils.scheduler.get_scheduler_status").then(({ status }) => { if (status === "active") { listview.page.set_indicator(__("Scheduler: Active"), "green"); diff --git a/frappe/core/page/recorder/recorder.js b/frappe/core/page/recorder/recorder.js deleted file mode 100644 index 1f004915fe..0000000000 --- a/frappe/core/page/recorder/recorder.js +++ /dev/null @@ -1,28 +0,0 @@ -frappe.pages["recorder"].on_page_load = function (wrapper) { - frappe.ui.make_app_page({ - parent: wrapper, - title: __("Recorder"), - single_column: true, - card_layout: true, - }); - - frappe.recorder = new Recorder(wrapper); - $(wrapper).bind("show", function () { - frappe.recorder.show(); - }); - - frappe.require("recorder.bundle.js"); -}; - -class Recorder { - constructor(wrapper) { - this.wrapper = $(wrapper); - this.container = this.wrapper.find(".layout-main-section"); - this.container.append($('
')); - } - - show() { - if (!this.route || this.route.name == "RecorderDetail") return; - this.router?.replace({ name: "RecorderDetail" }); - } -} diff --git a/frappe/core/page/recorder/recorder.json b/frappe/core/page/recorder/recorder.json deleted file mode 100644 index 43dfbc0e09..0000000000 --- a/frappe/core/page/recorder/recorder.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "content": null, - "creation": "2019-02-08 08:17:45.392739", - "docstatus": 0, - "doctype": "Page", - "idx": 0, - "modified": "2019-02-08 08:23:04.416426", - "modified_by": "Administrator", - "module": "Core", - "name": "recorder", - "owner": "Administrator", - "page_name": "Recorder", - "roles": [ - { - "role": "Administrator" - } - ], - "script": null, - "standard": "Yes", - "style": null, - "system_page": 0, - "title": "Recorder" -} \ No newline at end of file diff --git a/frappe/patches.txt b/frappe/patches.txt index 054fe9b946..c16bf8d1d7 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -193,6 +193,7 @@ frappe.patches.v14_0.delete_payment_gateways frappe.patches.v15_0.remove_event_streaming frappe.patches.v15_0.copy_disable_prepared_report_to_prepared_report execute:frappe.reload_doc("desk", "doctype", "Form Tour") +execute:frappe.delete_doc('Page', 'recorder', ignore_missing=True, force=True) [post_model_sync] execute:frappe.get_doc('Role', 'Guest').save() # remove desk access diff --git a/frappe/public/js/frappe/recorder/RecorderDetail.vue b/frappe/public/js/frappe/recorder/RecorderDetail.vue deleted file mode 100644 index c201c1528e..0000000000 --- a/frappe/public/js/frappe/recorder/RecorderDetail.vue +++ /dev/null @@ -1,306 +0,0 @@ - - - - - diff --git a/frappe/public/js/frappe/recorder/RecorderRoot.vue b/frappe/public/js/frappe/recorder/RecorderRoot.vue deleted file mode 100644 index 7c802ccec1..0000000000 --- a/frappe/public/js/frappe/recorder/RecorderRoot.vue +++ /dev/null @@ -1,20 +0,0 @@ - - - diff --git a/frappe/public/js/frappe/recorder/RequestDetail.vue b/frappe/public/js/frappe/recorder/RequestDetail.vue deleted file mode 100644 index 7d6e89f7ed..0000000000 --- a/frappe/public/js/frappe/recorder/RequestDetail.vue +++ /dev/null @@ -1,321 +0,0 @@ - - - diff --git a/frappe/public/js/frappe/recorder/recorder.bundle.js b/frappe/public/js/frappe/recorder/recorder.bundle.js deleted file mode 100644 index 4f6fb59d26..0000000000 --- a/frappe/public/js/frappe/recorder/recorder.bundle.js +++ /dev/null @@ -1,8 +0,0 @@ -import { createApp } from "vue"; -import RecorderRoot from "./RecorderRoot.vue"; -import router from "./router.js"; - -let app = createApp(RecorderRoot).use(router); -SetVueGlobals(app); -app.mount(".recorder-container"); -frappe.recorder.view = app; diff --git a/frappe/public/js/frappe/recorder/router.js b/frappe/public/js/frappe/recorder/router.js deleted file mode 100644 index ebff6eba7c..0000000000 --- a/frappe/public/js/frappe/recorder/router.js +++ /dev/null @@ -1,28 +0,0 @@ -import { createWebHistory, createRouter } from "vue-router"; -import RecorderDetail from "./RecorderDetail.vue"; -import RequestDetail from "./RequestDetail.vue"; - -const routes = [ - { - path: "/detail", - name: "RecorderDetail", - component: RecorderDetail, - }, - { - path: "/request/:id", - name: "RequestDetail", - component: RequestDetail, - meta: { shouldFetch: true }, - }, - { - path: "/", - redirect: "/detail", - }, -]; - -const router = createRouter({ - history: createWebHistory("/app/recorder/"), - routes, -}); - -export default router; diff --git a/frappe/recorder.py b/frappe/recorder.py index ac1c4951ac..2bc14e9f2f 100644 --- a/frappe/recorder.py +++ b/frappe/recorder.py @@ -193,6 +193,7 @@ def _unpatch(): def do_not_record(function): + @functools.wraps(function) def wrapper(*args, **kwargs): if hasattr(frappe.local, "_recorder"): del frappe.local._recorder @@ -203,6 +204,7 @@ def do_not_record(function): def administrator_only(function): + @functools.wraps(function) def wrapper(*args, **kwargs): if frappe.session.user != "Administrator": frappe.throw(_("Only Administrator is allowed to use Recorder")) @@ -274,3 +276,15 @@ def record_queries(func: Callable): return ret return wrapped + + +@frappe.whitelist() +@do_not_record +@administrator_only +def import_data(file: str) -> None: + file_doc = frappe.get_doc("File", {"file_url": file}) + file_content = json.loads(file_doc.get_content()) + for request in file_content: + frappe.cache.hset(RECORDER_REQUEST_SPARSE_HASH, request["uuid"], request) + frappe.cache.hset(RECORDER_REQUEST_HASH, request["uuid"], request) + file_doc.delete(delete_permanently=True) diff --git a/frappe/tests/test_virtual_doctype.py b/frappe/tests/test_virtual_doctype.py index 78f7fe4d14..f21cd140cd 100644 --- a/frappe/tests/test_virtual_doctype.py +++ b/frappe/tests/test_virtual_doctype.py @@ -11,6 +11,7 @@ from frappe.model.virtual_doctype import validate_controller from frappe.tests.utils import FrappeTestCase TEST_DOCTYPE_NAME = "VirtualDoctypeTest" +TEST_CHILD_DOCTYPE_NAME = "VirtualDoctypeTestChild" class VirtualDoctypeTest(Document): @@ -87,8 +88,22 @@ class TestVirtualDoctypes(FrappeTestCase): frappe.flags.allow_doctype_export = True cls.addClassCleanup(frappe.flags.pop, "allow_doctype_export", None) - vdt = new_doctype(name=TEST_DOCTYPE_NAME, is_virtual=1, custom=0).insert() + cdt = new_doctype(name=TEST_CHILD_DOCTYPE_NAME, is_virtual=1, istable=1, custom=0).insert() + vdt = new_doctype( + name=TEST_DOCTYPE_NAME, + is_virtual=1, + custom=0, + fields=[ + { + "label": "Child Table", + "fieldname": "child_table", + "fieldtype": "Table", + "options": TEST_CHILD_DOCTYPE_NAME, + } + ], + ).insert() cls.addClassCleanup(vdt.delete, force=True) + cls.addClassCleanup(cdt.delete, force=True) patch_virtual_doc = patch( "frappe.controllers", new={frappe.local.site: {TEST_DOCTYPE_NAME: VirtualDoctypeTest}} @@ -120,16 +135,20 @@ class TestVirtualDoctypes(FrappeTestCase): docname = frappe.response.docs[0]["name"] doc = frappe.get_doc(TEST_DOCTYPE_NAME, docname) - doc.some_fieldname = "New Data" + + doc.update({"child_table": [{"name": "child-1", "some_fieldname": "child1-field-value"}]}) savedocs(doc.as_json(), "Save") - doc.reload() - self.assertEqual(doc.some_fieldname, "New Data") + self.assertEqual(doc.child_table[0].some_fieldname, "child1-field-value") def test_multiple_doc_insert_and_get_list(self): - doc1 = frappe.get_doc(doctype=TEST_DOCTYPE_NAME, some_fieldname="first").insert() - doc2 = frappe.get_doc(doctype=TEST_DOCTYPE_NAME, some_fieldname="second").insert() + doc1 = frappe.new_doc(doctype=TEST_DOCTYPE_NAME) + doc1.append("child_table", {"name": "first", "some_fieldname": "first-value"}) + doc1.insert() + doc2 = frappe.new_doc(doctype=TEST_DOCTYPE_NAME) + doc2.append("child_table", {"name": "second", "some_fieldname": "second-value"}) + doc2.insert() docs = {doc1.name, doc2.name} @@ -146,7 +165,7 @@ class TestVirtualDoctypes(FrappeTestCase): self.assertIsInstance(VirtualDoctypeTest.get_count(args), int) def test_delete_doc(self): - doc = frappe.get_doc(doctype=TEST_DOCTYPE_NAME, some_fieldname="data").insert() + doc = frappe.get_doc(doctype=TEST_DOCTYPE_NAME).insert() frappe.delete_doc(doc.doctype, doc.name) @@ -155,3 +174,4 @@ class TestVirtualDoctypes(FrappeTestCase): def test_controller_validity(self): validate_controller(TEST_DOCTYPE_NAME) + validate_controller(TEST_CHILD_DOCTYPE_NAME)