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 +=
+ "";
+ 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 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ request[columns[0].slug] }}
-
-
-
- {{ request[column.slug] }}
-
-
-
-
-
-
-
-
-
-
-
-
-
{{ __("Recorder is Inactive.") }}
-
{{ __("Start recording or drag & drop a previously exported data file to view it.") }}
-
-
-
{{ __("No Requests found") }}
-
{{ __("Go make some noise") }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
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)