diff --git a/.github/helper/install_dependencies.sh b/.github/helper/install_dependencies.sh index b8da022665..574144b823 100644 --- a/.github/helper/install_dependencies.sh +++ b/.github/helper/install_dependencies.sh @@ -4,6 +4,7 @@ set -e echo "Setting Up System Dependencies..." sudo apt update +sudo apt remove mysql-server mysql-client sudo apt install libcups2-dev redis-server mariadb-client-10.6 install_wkhtmltopdf() { diff --git a/CODEOWNERS b/CODEOWNERS index 861016710a..e636e6c9fb 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -4,9 +4,3 @@ # the repo. Unless a later match takes precedence, * @frappe/frappe-review-team -templates/ @surajshetty3416 -www/ @surajshetty3416 -patches/ @surajshetty3416 -data_import* @netchampfaris -core/ @surajshetty3416 -workspace @shariquerik diff --git a/cypress/integration/control_attach.js b/cypress/integration/control_attach.js index 96b8c73b6e..6714f6c24e 100644 --- a/cypress/integration/control_attach.js +++ b/cypress/integration/control_attach.js @@ -92,4 +92,47 @@ context("Attach Control", () => { cy.get('.actions-btn-group > .dropdown-menu [data-label="Delete"]').click(); cy.click_modal_primary_button("Yes"); }); + + it('Checking that "Camera" button in the "Attach" fieldtype does show if camera is available', () => { + //Navigating to the new form for the newly created doctype + let doctype = "Test Attach Control"; + let dt_in_route = doctype.toLowerCase().replace(/ /g, "-"); + cy.visit(`/app/${dt_in_route}/new`, { + onBeforeLoad(win) { + // Mock "window.navigator.mediaDevices" property + // to return mock mediaDevices object + win.navigator.mediaDevices = { + ondevicechange: null, + }; + }, + }); + cy.get("body").should("have.attr", "data-route", `Form/${doctype}/new-${dt_in_route}-1`); + cy.get("body").should("have.attr", "data-ajax-state", "complete"); + + //Clicking on the attach button which is displayed as part of creating a doctype with "Attach" fieldtype + cy.findByRole("button", { name: "Attach" }).click(); + + //Clicking on "Camera" button + cy.findByRole("button", { name: "Camera" }).should("exist"); + }); + + it('Checking that "Camera" button in the "Attach" fieldtype does not show if no camera is available', () => { + //Navigating to the new form for the newly created doctype + let doctype = "Test Attach Control"; + let dt_in_route = doctype.toLowerCase().replace(/ /g, "-"); + cy.visit(`/app/${dt_in_route}/new`, { + onBeforeLoad(win) { + // Delete "window.navigator.mediaDevices" property + delete win.navigator.mediaDevices; + }, + }); + cy.get("body").should("have.attr", "data-route", `Form/${doctype}/new-${dt_in_route}-1`); + cy.get("body").should("have.attr", "data-ajax-state", "complete"); + + //Clicking on the attach button which is displayed as part of creating a doctype with "Attach" fieldtype + cy.findByRole("button", { name: "Attach" }).click(); + + //Clicking on "Camera" button + cy.findByRole("button", { name: "Camera" }).should("not.exist"); + }); }); diff --git a/cypress/integration/control_color.js b/cypress/integration/control_color.js index e97dbe0f06..aa3a45eed8 100644 --- a/cypress/integration/control_color.js +++ b/cypress/integration/control_color.js @@ -26,7 +26,7 @@ context("Control Color", () => { //Checking if the css attribute is correct cy.get(".color-map").should("have.css", "color", "rgb(79, 157, 217)"); - cy.get(".hue-map").should("have.css", "color", "rgb(0, 144, 255)"); + cy.get(".hue-map").should("have.css", "color", "rgb(0, 145, 255)"); //Checking if the correct color is being selected cy.get("@dialog").then((dialog) => { diff --git a/esbuild/esbuild.js b/esbuild/esbuild.js index 8937a03216..e22613e0e9 100644 --- a/esbuild/esbuild.js +++ b/esbuild/esbuild.js @@ -89,12 +89,10 @@ const NODE_PATHS = [].concat( app_list.map((app) => path.resolve(get_app_path(app), "..")).filter(fs.existsSync) ); -execute() - .then(() => RUN_BUILD_COMMAND && run_build_command_for_apps(APPS)) - .catch((e) => { - console.error(e); - process.exit(1); - }); +execute().catch((e) => { + console.error(e); + process.exit(1); +}); if (WATCH_MODE) { // listen for open files in editor event @@ -127,6 +125,10 @@ async function execute() { for (const result of results) { await write_assets_json(result.metafile); } + RUN_BUILD_COMMAND && run_build_command_for_apps(APPS); + if (!WATCH_MODE) { + process.exit(0); + } } function build_assets_for_apps(apps, files) { @@ -418,18 +420,17 @@ async function write_assets_json(metafile) { }; } -function update_assets_json_in_cache() { +async function update_assets_json_in_cache() { // update assets_json cache in redis, so that it can be read directly by python - return new Promise((resolve) => { - let client = get_redis_subscriber("redis_cache"); - // handle error event to avoid printing stack traces - client.on("error", (_) => { - log_warn("Cannot connect to redis_cache to update assets_json"); - }); - client.del("assets_json", (err) => { - client.unref(); - resolve(); - }); + let client = get_redis_subscriber("redis_cache"); + // handle error event to avoid printing stack traces + try { + await client.connect(); + } catch (e) { + log_warn("Cannot connect to redis_cache to update assets_json"); + } + client.del("assets_json", (err) => { + client.unref(); }); } @@ -458,9 +459,11 @@ function run_build_command_for_apps(apps) { async function notify_redis({ error, success, changed_files }) { // notify redis which in turns tells socketio to publish this to browser let subscriber = get_redis_subscriber("redis_queue"); - subscriber.on("error", (_) => { + try { + await subscriber.connect(); + } catch (e) { log_warn("Cannot connect to redis_queue for browser events"); - }); + } let payload = null; if (error) { @@ -483,7 +486,7 @@ async function notify_redis({ error, success, changed_files }) { }; } - subscriber.publish( + await subscriber.publish( "events", JSON.stringify({ event: "build_event", @@ -492,21 +495,20 @@ async function notify_redis({ error, success, changed_files }) { ); } -function open_in_editor() { +async function open_in_editor() { let subscriber = get_redis_subscriber("redis_queue"); - subscriber.on("error", (_) => { + try { + await subscriber.connect(); + } catch (e) { log_warn("Cannot connect to redis_queue for open_in_editor events"); + } + subscriber.subscribe("open_in_editor", (file) => { + file = JSON.parse(file); + let file_path = path.resolve(file.file); + log("Opening file in editor:", file_path); + let launch = require("launch-editor"); + launch(`${file_path}:${file.line}:${file.column}`); }); - subscriber.on("message", (event, file) => { - if (event === "open_in_editor") { - file = JSON.parse(file); - let file_path = path.resolve(file.file); - log("Opening file in editor:", file_path); - let launch = require("launch-editor"); - launch(`${file_path}:${file.line}:${file.column}`); - } - }); - subscriber.subscribe("open_in_editor"); } function get_rebuilt_assets(prev_assets, new_assets) { diff --git a/frappe/__init__.py b/frappe/__init__.py index b3a5c54fa5..9a83443b02 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -53,12 +53,8 @@ local = Local() cache = None STANDARD_USERS = ("Guest", "Administrator") -_dev_server = int(sbool(os.environ.get("DEV_SERVER", False))) _qb_patched = {} -re._MAXCACHE = ( - 50 # reduced from default 512 given we are already maintaining this on parent worker -) - +_dev_server = int(sbool(os.environ.get("DEV_SERVER", False))) _tune_gc = bool(sbool(os.environ.get("FRAPPE_TUNE_GC", True))) if _dev_server: @@ -1464,13 +1460,11 @@ def get_all_apps(with_internal_apps=True, sites_path=None): @request_cache -def get_installed_apps(*, _ensure_on_bench=False): +def get_installed_apps(*, _ensure_on_bench=False) -> list[str]: """ Get list of installed apps in current site. - :param sort: [DEPRECATED] Sort installed apps based on the sequence in sites/apps.txt - :param frappe_last: [DEPRECATED] Keep frappe last. Do not use this, reverse the app list instead. - :param ensure_on_bench: Only return apps that are present on bench. + :param _ensure_on_bench: Only return apps that are present on bench. """ if getattr(flags, "in_install_db", True): return [] @@ -2450,3 +2444,6 @@ if _tune_gc: # everything else. g0, g1, g2 = gc.get_threshold() # defaults are 700, 10, 10. gc.set_threshold(g0 * 10, g1 * 2, g2 * 2) + +# Remove references to pattern that are pre-compiled and loaded to global scopes. +re.purge() diff --git a/frappe/app.py b/frappe/app.py index cdb5d6ad21..137165c1e9 100644 --- a/frappe/app.py +++ b/frappe/app.py @@ -4,6 +4,7 @@ import gc import logging import os +import re from werkzeug.exceptions import HTTPException, NotFound from werkzeug.local import LocalManager @@ -428,6 +429,9 @@ def serve( ) +# Remove references to pattern that are pre-compiled and loaded to global scopes. +re.purge() + # Both Gunicorn and RQ use forking to spawn workers. In an ideal world, the fork should be sharing # most of the memory if there are no writes made to data because of Copy on Write, however, # python's GC is not CoW friendly and writes to data even if user-code doesn't. Specifically, the diff --git a/frappe/contacts/doctype/contact/test_contact.py b/frappe/contacts/doctype/contact/test_contact.py index 18f0d78732..b5f1c4bdf8 100644 --- a/frappe/contacts/doctype/contact/test_contact.py +++ b/frappe/contacts/doctype/contact/test_contact.py @@ -49,11 +49,13 @@ class TestContact(FrappeTestCase): # First time from database results = get_contact_list("_Test Supplier") self.assertEqual(results[0].label, "test_contact@example.com") + self.assertEqual(results[0].value, "test_contact@example.com") self.assertEqual(results[0].description, "_Test Contact For _Test Supplier") # Second time from cache results = get_contact_list("_Test Supplier") self.assertEqual(results[0].label, "test_contact@example.com") + self.assertEqual(results[0].value, "test_contact@example.com") self.assertEqual(results[0].description, "_Test Contact For _Test Supplier") diff --git a/frappe/core/doctype/activity_log/activity_log.js b/frappe/core/doctype/activity_log/activity_log.js index 7df644a86a..39486dac6f 100644 --- a/frappe/core/doctype/activity_log/activity_log.js +++ b/frappe/core/doctype/activity_log/activity_log.js @@ -2,5 +2,8 @@ // For license information, please see license.txt frappe.ui.form.on("Activity Log", { - refresh: function () {}, + refresh: function (frm) { + // Nothing in this form is supposed to be editable. + frm.disable_form(); + }, }); diff --git a/frappe/core/doctype/activity_log/activity_log.json b/frappe/core/doctype/activity_log/activity_log.json index 910baceb5e..b272bab180 100644 --- a/frappe/core/doctype/activity_log/activity_log.json +++ b/frappe/core/doctype/activity_log/activity_log.json @@ -13,6 +13,7 @@ "column_break_5", "additional_info", "communication_date", + "ip_address", "column_break_7", "operation", "status", @@ -148,12 +149,17 @@ "fieldtype": "Data", "in_list_view": 1, "label": "Full Name" + }, + { + "fieldname": "ip_address", + "fieldtype": "Data", + "label": "IP Address" } ], "icon": "fa fa-comment", "index_web_pages_for_search": 1, "links": [], - "modified": "2022-09-13 15:19:42.474114", + "modified": "2023-07-28 13:26:32.281278", "modified_by": "Administrator", "module": "Core", "name": "Activity Log", diff --git a/frappe/core/doctype/activity_log/activity_log.py b/frappe/core/doctype/activity_log/activity_log.py index f7c21f6825..d58899f6cd 100644 --- a/frappe/core/doctype/activity_log/activity_log.py +++ b/frappe/core/doctype/activity_log/activity_log.py @@ -21,6 +21,7 @@ class ActivityLog(Document): communication_date: DF.Datetime | None content: DF.TextEditor | None full_name: DF.Data | None + ip_address: DF.Data | None link_doctype: DF.Link | None link_name: DF.DynamicLink | None operation: DF.Literal["", "Login", "Logout"] @@ -40,6 +41,7 @@ class ActivityLog(Document): def validate(self): self.set_status() set_timeline_doc(self) + self.set_ip_address() def set_status(self): if not self.is_new(): @@ -48,6 +50,10 @@ class ActivityLog(Document): if self.reference_doctype and self.reference_name: self.status = "Linked" + def set_ip_address(self): + if self.operation in ("Login", "Logout"): + self.ip_address = getattr(frappe.local, "request_ip") + @staticmethod def clear_old_logs(days=None): if not days: diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index 65f0b826b9..f7e6f28527 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -240,9 +240,7 @@ class DocType(Document): controller = Document available_objects = {x for x in dir(controller) if isinstance(x, str)} - property_set = { - x for x in available_objects if isinstance(getattr(controller, x, None), property) - } + property_set = {x for x in available_objects if is_a_property(getattr(controller, x, None))} method_set = { x for x in available_objects if x not in property_set and callable(getattr(controller, x, None)) } @@ -1795,13 +1793,18 @@ def make_module_and_roles(doc, perm_fieldname="permissions"): raise +def is_a_property(x) -> bool: + """Get properties (@property, @cached_property) in a controller class""" + from functools import cached_property + + return isinstance(x, (property, cached_property)) + + def check_fieldname_conflicts(docfield): """Checks if fieldname conflicts with methods or properties""" doc = frappe.get_doc({"doctype": docfield.dt}) available_objects = [x for x in dir(doc) if isinstance(x, str)] - property_list = [ - x for x in available_objects if isinstance(getattr(type(doc), x, None), property) - ] + property_list = [x for x in available_objects if is_a_property(getattr(type(doc), x, None))] method_list = [ x for x in available_objects if x not in property_list and callable(getattr(doc, x)) ] diff --git a/frappe/core/doctype/doctype/test_doctype.py b/frappe/core/doctype/doctype/test_doctype.py index c819663962..40c55c594e 100644 --- a/frappe/core/doctype/doctype/test_doctype.py +++ b/frappe/core/doctype/doctype/test_doctype.py @@ -1,7 +1,9 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE +import os import random import string +import unittest from unittest.mock import patch import frappe @@ -172,6 +174,9 @@ class TestDocType(FrappeTestCase): if condition: self.assertFalse(re.match(pattern, condition)) + @unittest.skipUnless( + os.access(frappe.get_app_path("frappe"), os.W_OK), "Only run if frappe app paths is writable" + ) def test_sync_field_order(self): import os @@ -648,6 +653,9 @@ class TestDocType(FrappeTestCase): def test_no_delete_doc(self): self.assertRaises(frappe.ValidationError, frappe.delete_doc, "DocType", "Address") + @unittest.skipUnless( + os.access(frappe.get_app_path("frappe"), os.W_OK), "Only run if frappe app paths is writable" + ) @patch.dict(frappe.conf, {"developer_mode": 1}) def test_export_types(self): """Export python types.""" @@ -686,6 +694,9 @@ class TestDocType(FrappeTestCase): doctype.delete() frappe.db.commit() + @unittest.skipUnless( + os.access(frappe.get_app_path("frappe"), os.W_OK), "Only run if frappe app paths is writable" + ) @patch.dict(frappe.conf, {"developer_mode": 1}) def test_custom_field_deletion(self): """Custom child tables whose doctype doesn't exist should be auto deleted.""" @@ -698,6 +709,9 @@ class TestDocType(FrappeTestCase): frappe.delete_doc("DocType", child) self.assertFalse(frappe.get_meta(doctype).get_field(field)) + @unittest.skipUnless( + os.access(frappe.get_app_path("frappe"), os.W_OK), "Only run if frappe app paths is writable" + ) @patch.dict(frappe.conf, {"developer_mode": 1}) def test_delete_doctype_with_customization(self): from frappe.custom.doctype.property_setter.property_setter import make_property_setter diff --git a/frappe/core/doctype/error_log/test_error_log.py b/frappe/core/doctype/error_log/test_error_log.py index 3f19a6dd0c..22eeea329e 100644 --- a/frappe/core/doctype/error_log/test_error_log.py +++ b/frappe/core/doctype/error_log/test_error_log.py @@ -1,10 +1,12 @@ # Copyright (c) 2015, Frappe Technologies and Contributors # License: MIT. See LICENSE +from unittest.mock import patch + from ldap3.core.exceptions import LDAPException, LDAPInappropriateAuthenticationResult import frappe from frappe.tests.utils import FrappeTestCase -from frappe.utils.error import _is_ldap_exception +from frappe.utils.error import _is_ldap_exception, guess_exception_source # test_records = frappe.get_test_records('Error Log') @@ -21,3 +23,44 @@ class TestErrorLog(FrappeTestCase): for e in exc: self.assertTrue(_is_ldap_exception(e())) + + +_RAW_EXC = """ + File "apps/frappe/frappe/model/document.py", line 1284, in runner + add_to_return_value(self, fn(self, *args, **kwargs)) + ^^^^^^^^^^^^^^^^^^^^^^^^^ + File "apps/frappe/frappe/model/document.py", line 933, in fn + return method_object(*args, **kwargs) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "apps/erpnext/erpnext/selling/doctype/sales_order/sales_order.py", line 58, in onload + raise Exception("what") + Exception: what +""" + +_THROW_EXC = """ + File "apps/frappe/frappe/model/document.py", line 933, in fn + return method_object(*args, **kwargs) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "apps/erpnext/erpnext/selling/doctype/sales_order/sales_order.py", line 58, in onload + frappe.throw("what") + File "apps/frappe/frappe/__init__.py", line 550, in throw + msgprint( + File "apps/frappe/frappe/__init__.py", line 518, in msgprint + _raise_exception() + File "apps/frappe/frappe/__init__.py", line 467, in _raise_exception + raise raise_exception(msg) + frappe.exceptions.ValidationError: what +""" + +TEST_EXCEPTIONS = { + "erpnext (app)": _RAW_EXC, + "erpnext (app)": _THROW_EXC, +} + + +class TestExceptionSourceGuessing(FrappeTestCase): + @patch.object(frappe, "get_installed_apps", return_value=["frappe", "erpnext", "3pa"]) + def test_exc_source_guessing(self, _installed_apps): + for source, exc in TEST_EXCEPTIONS.items(): + result = guess_exception_source(exc) + self.assertEqual(result, source) diff --git a/frappe/core/doctype/module_def/module_def.py b/frappe/core/doctype/module_def/module_def.py index f0cbda34cd..9acc9953c6 100644 --- a/frappe/core/doctype/module_def/module_def.py +++ b/frappe/core/doctype/module_def/module_def.py @@ -6,6 +6,7 @@ import os import frappe from frappe.model.document import Document +from frappe.modules.export_file import delete_folder class ModuleDef(Document): @@ -22,6 +23,7 @@ class ModuleDef(Document): module_name: DF.Data package: DF.Link | None restrict_to_domain: DF.Link | None + # end: auto-generated types def on_update(self): """If in `developer_mode`, create folder for module and @@ -64,6 +66,7 @@ class ModuleDef(Document): modules = None if frappe.local.module_app.get(frappe.scrub(self.name)): + delete_folder(self.module_name, "Module Def", self.name) with open(frappe.get_app_path(self.app_name, "modules.txt")) as f: content = f.read() if self.name in content.splitlines(): diff --git a/frappe/core/doctype/report/report.py b/frappe/core/doctype/report/report.py index 920fc3f806..64f125eae2 100644 --- a/frappe/core/doctype/report/report.py +++ b/frappe/core/doctype/report/report.py @@ -411,9 +411,7 @@ def get_group_by_column_label(args, meta): else: sql_fn_map = {"avg": "Average", "sum": "Sum"} aggregate_on_label = meta.get_label(args.aggregate_on) - label = _("{function} of {fieldlabel}").format( - function=sql_fn_map[args.aggregate_function], fieldlabel=aggregate_on_label - ) + label = _("{0} of {1}").format(_(sql_fn_map[args.aggregate_function]), _(aggregate_on_label)) return label diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json index 5efe87da25..95dc1af924 100644 --- a/frappe/core/doctype/system_settings/system_settings.json +++ b/frappe/core/doctype/system_settings/system_settings.json @@ -33,6 +33,7 @@ "apply_strict_user_permissions", "column_break_21", "allow_guests_to_upload_files", + "force_web_capture_mode_for_uploads", "security", "session_expiry", "document_share_key_expiry", @@ -563,12 +564,19 @@ "fieldtype": "Link", "label": "Reset Password Template", "options": "Email Template" + }, + { + "default": "0", + "description": "When uploading files, force the use of the web-based image capture. If this is unchecked, the default behavior is to use the mobile native camera when use from a mobile is detected.", + "fieldname": "force_web_capture_mode_for_uploads", + "fieldtype": "Check", + "label": "Force Web Capture Mode for Uploads" } ], "icon": "fa fa-cog", "issingle": 1, "links": [], - "modified": "2023-05-25 13:02:54.808773", + "modified": "2023-07-30 17:34:08.292152", "modified_by": "Administrator", "module": "Core", "name": "System Settings", diff --git a/frappe/core/workspace/welcome_workspace/welcome_workspace.json b/frappe/core/workspace/welcome_workspace/welcome_workspace.json new file mode 100644 index 0000000000..1b45434990 --- /dev/null +++ b/frappe/core/workspace/welcome_workspace/welcome_workspace.json @@ -0,0 +1,28 @@ +{ + "charts": [], + "content": "[{\"id\":\"2eyXSHwMTE\",\"type\":\"header\",\"data\":{\"text\":\"Hi,\",\"col\":12}},{\"id\":\"ZusKvFOXgu\",\"type\":\"paragraph\",\"data\":{\"text\":\"I guess you don't have access to any workspace yet, but you can create one just for yourself. Click on the Create Workspace button to create one.
\",\"col\":12}}]", + "creation": "2023-07-28 17:14:28.608321", + "custom_blocks": [], + "docstatus": 0, + "doctype": "Workspace", + "for_user": "", + "hide_custom": 0, + "icon": "image-view", + "idx": 1, + "is_hidden": 0, + "label": "Welcome Workspace", + "links": [], + "modified": "2023-07-28 20:15:32.222029", + "modified_by": "Administrator", + "module": "Core", + "name": "Welcome Workspace", + "number_cards": [], + "owner": "Administrator", + "parent_page": "", + "public": 1, + "quick_lists": [], + "roles": [], + "sequence_id": 22.0, + "shortcuts": [], + "title": "Welcome Workspace" +} \ No newline at end of file diff --git a/frappe/database/database.py b/frappe/database/database.py index a6254a0242..61cabc0478 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -314,7 +314,7 @@ class Database: if frappe.conf.logging == 2: _query = _query or str(mogrified_query) - frappe.log(f"<<<< query\n{_query}\n>>>>") + frappe.log(f"#### query\n{_query}\n####") if unmogrified_query and is_query_type( unmogrified_query, ("alter", "drop", "create", "truncate", "rename") diff --git a/frappe/desk/desktop.py b/frappe/desk/desktop.py index cf9f223d2a..141ac7d013 100644 --- a/frappe/desk/desktop.py +++ b/frappe/desk/desktop.py @@ -453,7 +453,7 @@ def get_workspace_sidebar_items(): try: workspace = Workspace(page, True) if has_access or workspace.is_permitted(): - if page.public and (has_access or not page.is_hidden): + if page.public and (has_access or not page.is_hidden) and page.title != "Welcome Workspace": pages.append(page) elif page.for_user == frappe.session.user: private_pages.append(page) @@ -463,6 +463,10 @@ def get_workspace_sidebar_items(): if private_pages: pages.extend(private_pages) + if len(pages) == 0: + pages = [frappe.get_doc("Workspace", "Welcome Workspace").as_dict()] + pages[0]["label"] = _("Welcome Workspace") + return {"pages": pages, "has_access": has_access} diff --git a/frappe/desk/doctype/system_console/system_console.py b/frappe/desk/doctype/system_console/system_console.py index 76b67e32df..540936581a 100644 --- a/frappe/desk/doctype/system_console/system_console.py +++ b/frappe/desk/doctype/system_console/system_console.py @@ -33,6 +33,7 @@ class SystemConsole(Document): elif self.type == "SQL": self.output = frappe.as_json(read_sql(self.console, as_dict=1)) except Exception: + self.commit = False self.output = frappe.get_traceback() if self.commit: diff --git a/frappe/desk/doctype/system_console/test_system_console.py b/frappe/desk/doctype/system_console/test_system_console.py index 2664f7c925..ade8704813 100644 --- a/frappe/desk/doctype/system_console/test_system_console.py +++ b/frappe/desk/doctype/system_console/test_system_console.py @@ -16,3 +16,16 @@ class TestSystemConsole(FrappeTestCase): system_console.run() self.assertEqual(system_console.output, "Core") + + def test_system_console_sql(self): + system_console = frappe.get_doc("System Console") + system_console.type = "SQL" + system_console.console = "select 'test'" + system_console.run() + + self.assertIn("test", system_console.output) + + system_console.console = "update `tabDocType` set is_virtual = 1 where name = 'xyz'" + system_console.run() + + self.assertIn("PermissionError", system_console.output) diff --git a/frappe/desk/doctype/workspace_shortcut/workspace_shortcut.json b/frappe/desk/doctype/workspace_shortcut/workspace_shortcut.json index 8832d9e1f4..e494aad152 100644 --- a/frappe/desk/doctype/workspace_shortcut/workspace_shortcut.json +++ b/frappe/desk/doctype/workspace_shortcut/workspace_shortcut.json @@ -9,6 +9,7 @@ "link_to", "url", "doc_view", + "kanban_board", "column_break_4", "label", "icon", @@ -43,7 +44,7 @@ "fieldtype": "Select", "in_list_view": 1, "label": "DocType View", - "options": "\nList\nReport Builder\nDashboard\nTree\nNew\nCalendar" + "options": "\nList\nReport Builder\nDashboard\nTree\nNew\nCalendar\nKanban" }, { "fieldname": "column_break_4", @@ -103,12 +104,19 @@ "in_list_view": 1, "label": "URL", "options": "URL" + }, + { + "depends_on": "eval:doc.doc_view == \"Kanban\"", + "fieldname": "kanban_board", + "fieldtype": "Link", + "label": "Kanban Board", + "options": "Kanban Board" } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2023-04-19 13:32:31.005443", + "modified": "2023-07-18 16:12:53.546430", "modified_by": "Administrator", "module": "Desk", "name": "Workspace Shortcut", @@ -117,5 +125,6 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/frappe/desk/doctype/workspace_shortcut/workspace_shortcut.py b/frappe/desk/doctype/workspace_shortcut/workspace_shortcut.py index 5b7cda15bf..9e908974fa 100644 --- a/frappe/desk/doctype/workspace_shortcut/workspace_shortcut.py +++ b/frappe/desk/doctype/workspace_shortcut/workspace_shortcut.py @@ -15,9 +15,12 @@ class WorkspaceShortcut(Document): from frappe.types import DF color: DF.Color | None - doc_view: DF.Literal["", "List", "Report Builder", "Dashboard", "Tree", "New", "Calendar"] + doc_view: DF.Literal[ + "", "List", "Report Builder", "Dashboard", "Tree", "New", "Calendar", "Kanban" + ] format: DF.Data | None icon: DF.Data | None + kanban_board: DF.Link | None label: DF.Data link_to: DF.DynamicLink | None parent: DF.Data diff --git a/frappe/desk/page/setup_wizard/setup_wizard.js b/frappe/desk/page/setup_wizard/setup_wizard.js index decf540097..36658fe492 100644 --- a/frappe/desk/page/setup_wizard/setup_wizard.js +++ b/frappe/desk/page/setup_wizard/setup_wizard.js @@ -638,9 +638,15 @@ frappe.setup.utils = { }, }; +// https://github.com/eggert/tz/blob/main/backward add more if required. +const TZ_BACKWARD_COMPATBILITY_MAP = { + "Asia/Calcutta": "Asia/Kolkata", +}; + function guess_country(country_info) { try { - const system_timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + let system_timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + system_timezone = TZ_BACKWARD_COMPATBILITY_MAP[system_timezone] || system_timezone; for (let [country, info] of Object.entries(country_info)) { let possible_timezones = (info.timezones || []).filter((t) => t == system_timezone); diff --git a/frappe/email/__init__.py b/frappe/email/__init__.py index 463f54d7e0..666c726942 100644 --- a/frappe/email/__init__.py +++ b/frappe/email/__init__.py @@ -16,7 +16,7 @@ def get_contact_list(txt, page_length=20) -> list[dict]: if cached_contacts := get_cached_contacts(txt): return cached_contacts[:page_length] - fields = ["name", "first_name", "middle_name", "last_name", "company_name"] + fields = ["first_name", "middle_name", "last_name", "company_name"] contacts = frappe.get_list( "Contact", fields=fields + ["`tabContact Email`.email_id"], @@ -33,7 +33,7 @@ def get_contact_list(txt, page_length=20) -> list[dict]: # https://github.com/frappe/frappe/blob/6c6a89bcdd9454060a1333e23b855d0505c9ebc2/frappe/public/js/frappe/form/controls/autocomplete.js#L29-L35 result = [ frappe._dict( - value=d.name, + value=d.email_id, label=d.email_id, description=get_full_name(d.first_name, d.middle_name, d.last_name, d.company_name), ) diff --git a/frappe/email/doctype/email_queue_recipient/email_queue_recipient.py b/frappe/email/doctype/email_queue_recipient/email_queue_recipient.py index 43b85c9f22..2d0c5678f7 100644 --- a/frappe/email/doctype/email_queue_recipient/email_queue_recipient.py +++ b/frappe/email/doctype/email_queue_recipient/email_queue_recipient.py @@ -33,3 +33,8 @@ class EmailQueueRecipient(Document): frappe.db.set_value(self.DOCTYPE, self.name, kwargs) if commit: frappe.db.commit() + + +def on_doctype_update(): + """Index required for log clearing, modified is not indexed on child table by default""" + frappe.db.add_index("Email Queue Recipient", ["modified"]) diff --git a/frappe/email/doctype/newsletter/newsletter.py b/frappe/email/doctype/newsletter/newsletter.py index 554ea79b08..09200e3635 100644 --- a/frappe/email/doctype/newsletter/newsletter.py +++ b/frappe/email/doctype/newsletter/newsletter.py @@ -411,7 +411,9 @@ def send_scheduled_email(): @frappe.whitelist(allow_guest=True) -def newsletter_email_read(recipient_email, reference_doctype, reference_name): +def newsletter_email_read(recipient_email=None, reference_doctype=None, reference_name=None): + if not (recipient_email and reference_name): + return verify_request() try: doc = frappe.get_cached_doc("Newsletter", reference_name) diff --git a/frappe/integrations/oauth2.py b/frappe/integrations/oauth2.py index bf71a68aaa..724d3b32a7 100644 --- a/frappe/integrations/oauth2.py +++ b/frappe/integrations/oauth2.py @@ -103,7 +103,7 @@ def authorize(**kwargs): else: if "openid" in scopes: scopes.remove("openid") - scopes.extend(["First Name", "Last Name", "Email", "Password", "User Image", "Roles"]) + scopes.extend(["Full Name", "Email", "User Image", "Roles"]) # Show Allow/Deny screen. response_html_params = frappe._dict( diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index 33c55a1691..85c42b94b2 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -5,6 +5,7 @@ import copy import json import re +from collections import Counter from datetime import datetime import frappe @@ -61,6 +62,8 @@ class DatabaseQuery: self.doctype = doctype self.tables = [] self.link_tables = [] + self.linked_table_aliases = {} + self.linked_table_counter = Counter() self.conditions = [] self.or_conditions = [] self.fields = None @@ -80,7 +83,7 @@ class DatabaseQuery: @property def query_tables(self): - return self.tables + [d.table_name for d in self.link_tables] + return self.tables + [d.table_alias for d in self.link_tables] def execute( self, @@ -269,7 +272,7 @@ class DatabaseQuery: # left join link tables for link in self.link_tables: - args.tables += f" {self.join} `tab{link.doctype}` on (`tab{link.doctype}`.`name` = {self.tables[0]}.`{link.fieldname}`)" + args.tables += f" {self.join} {link.table_name} {link.table_alias} on ({link.table_alias}.`name` = {self.tables[0]}.`{link.fieldname}`)" if self.grouped_or_conditions: self.conditions.append(f"({' or '.join(self.grouped_or_conditions)})") @@ -358,8 +361,10 @@ class DatabaseQuery: continue linked_doctype = linked_field.options if linked_field.fieldtype == "Link": - self.append_link_table(linked_doctype, linked_fieldname) - field = f"`tab{linked_doctype}`.`{fieldname}`" + linked_table = self.append_link_table(linked_doctype, linked_fieldname) + field = f"{linked_table.table_alias}.`{fieldname}`" + else: + field = f"`tab{linked_doctype}`.`{fieldname}`" if alias: field = f"{field} as {alias}" self.fields[self.fields.index(original_field)] = field @@ -469,11 +474,19 @@ class DatabaseQuery: table_name = field.split(".", 1)[0] + # Check if table_name is a linked_table alias + for linked_table in self.link_tables: + if linked_table.table_alias == table_name: + table_name = linked_table.table_name + break + if table_name.lower().startswith("group_concat("): table_name = table_name[13:] if not table_name[0] == "`": table_name = f"`{table_name}`" - if table_name not in self.query_tables: + if ( + table_name not in self.query_tables and table_name not in self.linked_table_aliases.values() + ): self.append_table(table_name) def append_table(self, table_name): @@ -482,14 +495,21 @@ class DatabaseQuery: self.check_read_permission(doctype) def append_link_table(self, doctype, fieldname): - for d in self.link_tables: - if d.doctype == doctype and d.fieldname == fieldname: - return + for linked_table in self.link_tables: + if linked_table.doctype == doctype and linked_table.fieldname == fieldname: + return linked_table self.check_read_permission(doctype) - self.link_tables.append( - frappe._dict(doctype=doctype, fieldname=fieldname, table_name=f"`tab{doctype}`") + self.linked_table_counter.update((doctype,)) + linked_table = frappe._dict( + doctype=doctype, + fieldname=fieldname, + table_name=f"`tab{doctype}`", + table_alias=f"`tab{doctype}_{self.linked_table_counter[doctype]}`", ) + self.linked_table_aliases[linked_table.table_alias.replace("`", "")] = linked_table.table_name + self.link_tables.append(linked_table) + return linked_table def check_read_permission(self, doctype: str, parent_doctype: str | None = None): if self.flags.ignore_permissions: @@ -653,7 +673,12 @@ class DatabaseQuery: # handle child / joined table fields elif "." in field: table, column = column.split(".", 1) - ch_doctype = table.replace("`", "").replace("tab", "", 1) + ch_doctype = table + + if ch_doctype in self.linked_table_aliases: + ch_doctype = self.linked_table_aliases[ch_doctype] + + ch_doctype = ch_doctype.replace("`", "").replace("tab", "", 1) if wrap_grave_quotes(table) in self.query_tables: permitted_child_table_fields = get_permitted_fields( @@ -815,14 +840,19 @@ class DatabaseQuery: elif f.operator.lower() == "is": if f.value == "set": f.operator = "!=" + # Value can technically be null, but comparing with null will always be falsy + # Not using coalesce here is faster because indexes can be used. + # null != '' -> null ~ falsy + # '' != '' -> false + can_be_null = False elif f.value == "not set": f.operator = "=" + fallback = "''" + can_be_null = True value = "" - fallback = "''" - can_be_null = True - if "ifnull" not in column_name.lower(): + if can_be_null and "ifnull" not in column_name.lower(): column_name = f"ifnull({column_name}, {fallback})" elif df and df.fieldtype == "Date": diff --git a/frappe/model/document.py b/frappe/model/document.py index 591576c962..3343a5dab8 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -1134,7 +1134,11 @@ class Document(BaseDocument): def reset_seen(self): """Clear _seen property and set current user as seen""" - if getattr(self.meta, "track_seen", False) and not getattr(self.meta, "issingle", False): + if ( + getattr(self.meta, "track_seen", False) + and not getattr(self.meta, "issingle", False) + and not self.is_new() + ): frappe.db.set_value( self.doctype, self.name, "_seen", json.dumps([frappe.session.user]), update_modified=False ) diff --git a/frappe/permissions.py b/frappe/permissions.py index ae26b56b2e..e71e2be20f 100644 --- a/frappe/permissions.py +++ b/frappe/permissions.py @@ -89,7 +89,7 @@ def has_permission( meta = frappe.get_meta(doctype) if doc: - if isinstance(doc, str): + if isinstance(doc, (str, int)): doc = frappe.get_doc(meta.name, doc) perm = get_doc_permissions(doc, user=user, ptype=ptype).get(ptype) if not perm: diff --git a/frappe/printing/doctype/print_format/test_print_format.py b/frappe/printing/doctype/print_format/test_print_format.py index 7caa5c6102..c54e421861 100644 --- a/frappe/printing/doctype/print_format/test_print_format.py +++ b/frappe/printing/doctype/print_format/test_print_format.py @@ -2,6 +2,7 @@ # License: MIT. See LICENSE import os import re +import unittest from typing import TYPE_CHECKING import frappe @@ -36,6 +37,9 @@ class TestPrintFormat(FrappeTestCase): print_html = self.test_print_user("Classic") self.assertTrue("/* classic format: for-test */" in print_html) + @unittest.skipUnless( + os.access(frappe.get_app_path("frappe"), os.W_OK), "Only run if frappe app paths is writable" + ) def test_export_doc(self): doc: "PrintFormat" = frappe.get_doc("Print Format", test_records[0]["name"]) diff --git a/frappe/public/js/form_builder/components/controls/CheckControl.vue b/frappe/public/js/form_builder/components/controls/CheckControl.vue index 023bcebf66..fbdb76396d 100644 --- a/frappe/public/js/form_builder/components/controls/CheckControl.vue +++ b/frappe/public/js/form_builder/components/controls/CheckControl.vue @@ -6,7 +6,7 @@ let slots = useSlots();