diff --git a/cypress/integration/kanban.js b/cypress/integration/kanban.js index 1b7e45ac55..04a72a9436 100644 --- a/cypress/integration/kanban.js +++ b/cypress/integration/kanban.js @@ -1,6 +1,6 @@ context("Kanban Board", () => { before(() => { - cy.login(); + cy.login("frappe@example.com"); cy.visit("/app"); }); @@ -96,4 +96,36 @@ context("Kanban Board", () => { .first() .should("not.contain", "ID:"); }); + + it("Checks if Kanban Board edits are blocked for non-System Manager and non-owner of the Board", () => { + // create admin kanban board + cy.call("frappe.tests.ui_test_helpers.create_todo", { description: "Frappe User ToDo" }); + + cy.switch_to_user("Administrator"); + cy.call("frappe.tests.ui_test_helpers.create_admin_kanban"); + // remove sys manager + cy.remove_role("frappe@example.com", "System Manager"); + + cy.switch_to_user("frappe@example.com"); + + cy.visit("/app/todo/view/kanban/Admin Kanban"); + + // Menu button should be hidden (dropdown for 'Save Filters' and 'Delete Kanban Board') + cy.get(".no-list-sidebar .menu-btn-group .btn-default[data-original-title='Menu']").should( + "have.length", + 0 + ); + // Kanban Columns should be visible (read-only) + cy.get(".kanban .kanban-column").should("have.length", 2); + // User should be able to add card (has access to ToDo) + cy.get(".kanban .add-card").should("have.length", 2); + // Column actions should be hidden (dropdown for 'Archive' and indicators) + cy.get(".kanban .column-options").should("have.length", 0); + + cy.add_role("frappe@example.com", "System Manager"); + }); + + after(() => { + cy.call("logout"); + }); }); diff --git a/cypress/integration/permissions.js b/cypress/integration/permissions.js index 7a13239771..4e371cc17f 100644 --- a/cypress/integration/permissions.js +++ b/cypress/integration/permissions.js @@ -1,16 +1,7 @@ context("Permissions API", () => { before(() => { cy.visit("/login"); - - cy.login("Administrator"); - cy.call("frappe.tests.ui_test_helpers.add_remove_role", { - action: "remove", - user: "frappe@example.com", - role: "System Manager", - }); - cy.call("logout"); - - cy.login("frappe@example.com"); + cy.remove_role("frappe@example.com", "System Manager"); cy.visit("/app"); }); @@ -44,14 +35,7 @@ context("Permissions API", () => { }); after(() => { - cy.call("logout"); - - cy.login("Administrator"); - cy.call("frappe.tests.ui_test_helpers.add_remove_role", { - action: "add", - user: "frappe@example.com", - role: "System Manager", - }); + cy.add_role("frappe@example.com", "System Manager"); cy.call("logout"); }); }); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 20de7508c0..0a25ff5cab 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -371,6 +371,45 @@ Cypress.Commands.add("update_doc", (doctype, docname, args) => { }); }); +Cypress.Commands.add("switch_to_user", (user) => { + cy.call("logout"); + cy.login(user); +}); + +Cypress.Commands.add("add_role", (user, role) => { + cy.window() + .its("frappe") + .then((frappe) => { + const session_user = frappe.session.user; + add_remove_role("add", user, role, session_user); + }); +}); + +Cypress.Commands.add("remove_role", (user, role) => { + cy.window() + .its("frappe") + .then((frappe) => { + const session_user = frappe.session.user; + add_remove_role("remove", user, role, session_user); + }); +}); + +const add_remove_role = (action, user, role, session_user) => { + if (session_user !== "Administrator") { + cy.switch_to_user("Administrator"); + } + + cy.call("frappe.tests.ui_test_helpers.add_remove_role", { + action: action, + user: user, + role: role, + }); + + if (session_user !== "Administrator") { + cy.switch_to_user(session_user); + } +}; + Cypress.Commands.add("open_list_filter", () => { cy.get(".filter-section .filter-button").click(); cy.wait(300); diff --git a/frappe/__init__.py b/frappe/__init__.py index 54f27ec5b8..da6dde08d8 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -1907,7 +1907,7 @@ def get_value(*args, **kwargs): return db.get_value(*args, **kwargs) -def as_json(obj: dict | list, indent=1, separators=None) -> str: +def as_json(obj: dict | list, indent=1, separators=None, ensure_ascii=True) -> str: from frappe.utils.response import json_handler if separators is None: @@ -1915,13 +1915,24 @@ def as_json(obj: dict | list, indent=1, separators=None) -> str: try: return json.dumps( - obj, indent=indent, sort_keys=True, default=json_handler, separators=separators + obj, + indent=indent, + sort_keys=True, + default=json_handler, + separators=separators, + ensure_ascii=ensure_ascii, ) except TypeError: # this would break in case the keys are not all os "str" type - as defined in the JSON # adding this to ensure keys are sorted (expected behaviour) sorted_obj = dict(sorted(obj.items(), key=lambda kv: str(kv[0]))) - return json.dumps(sorted_obj, indent=indent, default=json_handler, separators=separators) + return json.dumps( + sorted_obj, + indent=indent, + default=json_handler, + separators=separators, + ensure_ascii=ensure_ascii, + ) def are_emails_muted(): diff --git a/frappe/client.py b/frappe/client.py index 404617b68c..4dc118ea06 100644 --- a/frappe/client.py +++ b/frappe/client.py @@ -303,6 +303,17 @@ def has_permission(doctype, docname, perm_type="read"): return {"has_permission": frappe.has_permission(doctype, perm_type.lower(), docname)} +@frappe.whitelist() +def get_doc_permissions(doctype, docname): + """Returns an evaluated document permissions dict like `{"read":1, "write":1}` + + :param doctype: DocType of the document to be evaluated + :param docname: `name` of the document to be evaluated + """ + doc = frappe.get_doc(doctype, docname) + return {"permissions": frappe.permissions.get_doc_permissions(doc)} + + @frappe.whitelist() def get_password(doctype, name, fieldname): """Return a password type property. Only applicable for System Managers diff --git a/frappe/core/doctype/data_import/data_import.py b/frappe/core/doctype/data_import/data_import.py index c055524fd1..afa6f3d0fa 100644 --- a/frappe/core/doctype/data_import/data_import.py +++ b/frappe/core/doctype/data_import/data_import.py @@ -260,7 +260,7 @@ def export_json(doctype, path, filters=None, or_filters=None, name=None, order_b path = os.path.join("..", path) with open(path, "w") as outfile: - outfile.write(frappe.as_json(out)) + outfile.write(frappe.as_json(out, ensure_ascii=False)) def export_csv(doctype, path): diff --git a/frappe/core/doctype/docfield/docfield.py b/frappe/core/doctype/docfield/docfield.py index 9fe55df5fe..96dbfee4cb 100644 --- a/frappe/core/doctype/docfield/docfield.py +++ b/frappe/core/doctype/docfield/docfield.py @@ -29,3 +29,12 @@ class DocField(Document): if self.fieldtype == "Select": options = self.options or "" return [d for d in options.split("\n") if d] + + def __repr__(self): + unsaved = "unsaved" if not self.name else "" + doctype = self.__class__.__name__ + + docstatus = f" docstatus={self.docstatus}" if self.docstatus else "" + parent = f" parent={self.parent}" if getattr(self, "parent", None) else "" + + return f"<{self.fieldtype}{doctype}: {self.fieldname}{docstatus}{parent}{unsaved}>" diff --git a/frappe/core/doctype/domain/domain.py b/frappe/core/doctype/domain/domain.py index e0b0e80982..1f5dbfa22e 100644 --- a/frappe/core/doctype/domain/domain.py +++ b/frappe/core/doctype/domain/domain.py @@ -85,8 +85,8 @@ class Domain(Document): def set_default_portal_role(self): """Set default portal role based on domain""" if self.data.get("default_portal_role"): - frappe.db.set_value( - "Portal Settings", None, "default_role", self.data.get("default_portal_role") + frappe.db.set_single_value( + "Portal Settings", "default_role", self.data.get("default_portal_role") ) def setup_properties(self): diff --git a/frappe/core/doctype/user/test_user.py b/frappe/core/doctype/user/test_user.py index ef4b171e8e..0c5badb21f 100644 --- a/frappe/core/doctype/user/test_user.py +++ b/frappe/core/doctype/user/test_user.py @@ -27,9 +27,9 @@ test_records = frappe.get_test_records("User") class TestUser(FrappeTestCase): def tearDown(self): # disable password strength test - frappe.db.set_value("System Settings", "System Settings", "enable_password_policy", 0) - frappe.db.set_value("System Settings", "System Settings", "minimum_password_score", "") - frappe.db.set_value("System Settings", "System Settings", "password_reset_limit", 3) + frappe.db.set_single_value("System Settings", "enable_password_policy", 0) + frappe.db.set_single_value("System Settings", "minimum_password_score", "") + frappe.db.set_single_value("System Settings", "password_reset_limit", 3) frappe.set_user("Administrator") def test_user_type(self): @@ -111,7 +111,7 @@ class TestUser(FrappeTestCase): self.assertEqual(frappe.db.get_value("User", "xxxtest@example.com"), None) - frappe.db.set_value("Website Settings", "Website Settings", "_test", "_test_val") + frappe.db.set_single_value("Website Settings", "_test", "_test_val") self.assertEqual(frappe.db.get_value("Website Settings", None, "_test"), "_test_val") self.assertEqual( frappe.db.get_value("Website Settings", "Website Settings", "_test"), "_test_val" @@ -179,15 +179,15 @@ class TestUser(FrappeTestCase): def test_password_strength(self): # Test Password without Password Strength Policy - frappe.db.set_value("System Settings", "System Settings", "enable_password_policy", 0) + frappe.db.set_single_value("System Settings", "enable_password_policy", 0) # password policy is disabled, test_password_strength should be ignored result = test_password_strength("test_password") self.assertFalse(result.get("feedback", None)) # Test Password with Password Strenth Policy Set - frappe.db.set_value("System Settings", "System Settings", "enable_password_policy", 1) - frappe.db.set_value("System Settings", "System Settings", "minimum_password_score", 2) + frappe.db.set_single_value("System Settings", "enable_password_policy", 1) + frappe.db.set_single_value("System Settings", "minimum_password_score", 2) # Score 1; should now fail result = test_password_strength("bee2ve") @@ -275,7 +275,7 @@ class TestUser(FrappeTestCase): def test_rate_limiting_for_reset_password(self): # Allow only one reset request for a day - frappe.db.set_value("System Settings", "System Settings", "password_reset_limit", 1) + frappe.db.set_single_value("System Settings", "password_reset_limit", 1) frappe.db.commit() url = get_url() @@ -443,9 +443,7 @@ class TestUser(FrappeTestCase): def test_reset_password_link_expiry(self): new_password = "new_password" # set the reset password expiry to 1 second - frappe.db.set_value( - "System Settings", "System Settings", "reset_password_link_expiry_duration", 1 - ) + frappe.db.set_single_value("System Settings", "reset_password_link_expiry_duration", 1) frappe.set_user("testpassword@example.com") test_user = frappe.get_doc("User", "testpassword@example.com") test_user.reset_password() diff --git a/frappe/database/database.py b/frappe/database/database.py index 5b775ccc10..5b8be7d6e2 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -32,7 +32,7 @@ from frappe.model.utils.link_count import flush_local_link_count from frappe.query_builder.functions import Count from frappe.utils import cast as cast_fieldtype from frappe.utils import cint, get_datetime, get_table_name, getdate, now, sbool -from frappe.utils.deprecations import deprecated +from frappe.utils.deprecations import deprecated, deprecation_warning IFNULL_PATTERN = re.compile(r"ifnull\(", flags=re.IGNORECASE) INDEX_PATTERN = re.compile(r"\s*\([^)]+\)\s*") @@ -689,13 +689,30 @@ class Database: def get_list(*args, **kwargs): return frappe.get_list(*args, **kwargs) + @staticmethod + def _get_update_dict( + fieldname: str | dict, value: Any, *, modified: str, modified_by: str, update_modified: bool + ) -> dict[str, Any]: + """Create update dict that represents column-values to be updated.""" + update_dict = fieldname if isinstance(fieldname, dict) else {fieldname: value} + + if update_modified: + modified = modified or now() + modified_by = modified_by or frappe.session.user + update_dict.update({"modified": modified, "modified_by": modified_by}) + + return update_dict + def set_single_value( self, doctype: str, fieldname: str | dict, value: str | int | None = None, - *args, - **kwargs, + *, + modified=None, + modified_by=None, + update_modified=True, + debug=False, ): """Set field value of Single DocType. @@ -708,7 +725,23 @@ class Database: # Update the `deny_multiple_sessions` field in System Settings DocType. company = frappe.db.set_single_value("System Settings", "deny_multiple_sessions", True) """ - return self.set_value(doctype, doctype, fieldname, value, *args, **kwargs) + + to_update = self._get_update_dict( + fieldname, value, modified=modified, modified_by=modified_by, update_modified=update_modified + ) + + frappe.db.delete( + "Singles", filters={"field": ("in", tuple(to_update)), "doctype": doctype}, debug=debug + ) + + singles_data = ((doctype, key, sbool(value)) for key, value in to_update.items()) + frappe.qb.into("Singles").columns("doctype", "field", "value").insert(*singles_data).run( + debug=debug + ) + frappe.clear_document_cache(doctype, doctype) + + if doctype in self.value_cache: + del self.value_cache[doctype] def get_single_value(self, doctype, fieldname, cache=True): """Get property of Single DocType. Cache locally by default @@ -834,40 +867,40 @@ class Database: :param update_modified: default True. Set as false, if you don't want to update the timestamp. :param debug: Print the query in the developer / js console. """ - is_single_doctype = not (dn and dt != dn) - to_update = field if isinstance(field, dict) else {field: val} - if update_modified: - modified = modified or now() - modified_by = modified_by or frappe.session.user - to_update.update({"modified": modified, "modified_by": modified_by}) - - if is_single_doctype: - frappe.db.delete( - "Singles", filters={"field": ("in", tuple(to_update)), "doctype": dt}, debug=debug + if _is_single_doctype := not (dn and dt != dn): + deprecation_warning( + "Calling db.set_value on single doctype is deprecated. This behaviour will be removed in version 15. Use db.set_single_value instead." ) + self.set_single_value( + doctype=dt, + fieldname=field, + value=val, + debug=debug, + update_modified=update_modified, + modified=modified, + modified_by=modified_by, + ) + return - singles_data = ((dt, key, sbool(value)) for key, value in to_update.items()) - query = ( - frappe.qb.into("Singles").columns("doctype", "field", "value").insert(*singles_data) - ).run(debug=debug) - frappe.clear_document_cache(dt, dt) + to_update = self._get_update_dict( + field, val, modified=modified, modified_by=modified_by, update_modified=update_modified + ) + query = frappe.qb.engine.build_conditions(table=dt, filters=dn, update=True) + + if isinstance(dn, str): + frappe.clear_document_cache(dt, dn) else: - query = frappe.qb.engine.build_conditions(table=dt, filters=dn, update=True) + # TODO: Fix this; doesn't work rn - gavin@frappe.io + # frappe.cache().hdel_keys(dt, "document_cache") + # Workaround: clear all document caches + frappe.cache().delete_value("document_cache") - if isinstance(dn, str): - frappe.clear_document_cache(dt, dn) - else: - # TODO: Fix this; doesn't work rn - gavin@frappe.io - # frappe.cache().hdel_keys(dt, "document_cache") - # Workaround: clear all document caches - frappe.cache().delete_value("document_cache") + for column, value in to_update.items(): + query = query.set(column, value) - for column, value in to_update.items(): - query = query.set(column, value) - - query.run(debug=debug) + query.run(debug=debug) if dt in self.value_cache: del self.value_cache[dt] diff --git a/frappe/desk/doctype/kanban_board/kanban_board.py b/frappe/desk/doctype/kanban_board/kanban_board.py index 83f0f46df0..e3257e25be 100644 --- a/frappe/desk/doctype/kanban_board/kanban_board.py +++ b/frappe/desk/doctype/kanban_board/kanban_board.py @@ -88,10 +88,15 @@ def update_order(board_name, order): """Save the order of cards in columns""" board = frappe.get_doc("Kanban Board", board_name) doctype = board.reference_doctype + updated_cards = [] + + if not frappe.has_permission(doctype, "write"): + # Return board data from db + return board, updated_cards + fieldname = board.field_name order_dict = json.loads(order) - updated_cards = [] for col_name, cards in order_dict.items(): for card in cards: column = frappe.get_value(doctype, {"name": card}, fieldname) @@ -103,8 +108,7 @@ def update_order(board_name, order): if column.column_name == col_name: column.order = json.dumps(cards) - board.save() - return board, updated_cards + return board.save(ignore_permissions=True), updated_cards @frappe.whitelist() @@ -114,6 +118,9 @@ def update_order_for_single_card( """Save the order of cards in columns""" board = frappe.get_doc("Kanban Board", board_name) doctype = board.reference_doctype + + frappe.has_permission(doctype, "write", throw=True) + fieldname = board.field_name old_index = frappe.parse_json(old_index) new_index = frappe.parse_json(new_index) @@ -130,7 +137,7 @@ def update_order_for_single_card( # save updated order board.columns[from_col_idx].order = frappe.as_json(from_col_order) board.columns[to_col_idx].order = frappe.as_json(to_col_order) - board.save() + board.save(ignore_permissions=True) # update changed value in doc frappe.set_value(doctype, docname, fieldname, to_colname) @@ -151,13 +158,14 @@ def get_kanban_column_order_and_index(board, colname): def add_card(board_name, docname, colname): board = frappe.get_doc("Kanban Board", board_name) + frappe.has_permission(board.reference_doctype, "write", throw=True) + col_order, col_idx = get_kanban_column_order_and_index(board, colname) col_order.insert(0, docname) board.columns[col_idx].order = frappe.as_json(col_order) - board.save() - return board + return board.save(ignore_permissions=True) @frappe.whitelist() diff --git a/frappe/desk/page/setup_wizard/setup_wizard.py b/frappe/desk/page/setup_wizard/setup_wizard.py index 408776fcb9..4d4d76207a 100755 --- a/frappe/desk/page/setup_wizard/setup_wizard.py +++ b/frappe/desk/page/setup_wizard/setup_wizard.py @@ -267,10 +267,10 @@ def add_all_roles_to(name): def disable_future_access(): frappe.db.set_default("desktop:home_page", "workspace") - frappe.db.set_value("System Settings", "System Settings", "setup_complete", 1) + frappe.db.set_single_value("System Settings", "setup_complete", 1) # Enable onboarding after install - frappe.db.set_value("System Settings", "System Settings", "enable_onboarding", 1) + frappe.db.set_single_value("System Settings", "enable_onboarding", 1) if not frappe.flags.in_test: # remove all roles and add 'Administrator' to prevent future access diff --git a/frappe/hooks.py b/frappe/hooks.py index beff7d2de2..9ad95e796b 100644 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -356,6 +356,7 @@ global_search_doctypes = { } override_whitelisted_methods = { + # Legacy File APIs "frappe.core.doctype.file.file.download_file": "download_file", "frappe.core.doctype.file.file.unzip_file": "frappe.core.api.file.unzip_file", "frappe.core.doctype.file.file.get_attached_images": "frappe.core.api.file.get_attached_images", @@ -365,6 +366,14 @@ override_whitelisted_methods = { "frappe.core.doctype.file.file.create_new_folder": "frappe.core.api.file.create_new_folder", "frappe.core.doctype.file.file.move_file": "frappe.core.api.file.move_file", "frappe.core.doctype.file.file.zip_files": "frappe.core.api.file.zip_files", + # Legacy (& Consistency) OAuth2 APIs + "frappe.www.login.login_via_google": "frappe.integrations.oauth2_logins.login_via_google", + "frappe.www.login.login_via_github": "frappe.integrations.oauth2_logins.login_via_github", + "frappe.www.login.login_via_facebook": "frappe.integrations.oauth2_logins.login_via_facebook", + "frappe.www.login.login_via_frappe": "frappe.integrations.oauth2_logins.login_via_frappe", + "frappe.www.login.login_via_office365": "frappe.integrations.oauth2_logins.login_via_office365", + "frappe.www.login.login_via_salesforce": "frappe.integrations.oauth2_logins.login_via_salesforce", + "frappe.www.login.login_via_fairlogin": "frappe.integrations.oauth2_logins.login_via_fairlogin", } ignore_links_on_delete = [ diff --git a/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py b/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py index dc9db2ccda..e6998a9d6d 100644 --- a/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py +++ b/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py @@ -393,7 +393,7 @@ def dropbox_auth_finish(return_access_token=False): def set_dropbox_access_token(access_token): - frappe.db.set_value("Dropbox Settings", None, "dropbox_access_token", access_token) + frappe.db.set_single_value("Dropbox Settings", "dropbox_access_token", access_token) frappe.db.commit() diff --git a/frappe/integrations/doctype/google_drive/google_drive.py b/frappe/integrations/doctype/google_drive/google_drive.py index 6ea1294cb0..9315e868fe 100644 --- a/frappe/integrations/doctype/google_drive/google_drive.py +++ b/frappe/integrations/doctype/google_drive/google_drive.py @@ -54,7 +54,7 @@ def authorize_access(reauthorize=False, code=None): if not oauth_code or reauthorize: if reauthorize: - frappe.db.set_value("Google Drive", None, "backup_folder_id", "") + frappe.db.set_single_value("Google Drive", "backup_folder_id", "") return oauth_obj.get_authentication_url( { "redirect": f"/app/Form/{quote('Google Drive')}", @@ -62,8 +62,7 @@ def authorize_access(reauthorize=False, code=None): ) r = oauth_obj.authorize(oauth_code) - frappe.db.set_value( - "Google Drive", + frappe.db.set_single_value( "Google Drive", {"authorization_code": oauth_code, "refresh_token": r.get("refresh_token")}, ) @@ -95,7 +94,7 @@ def check_for_folder_in_google_drive(): try: folder = google_drive.files().create(body=file_metadata, fields="id").execute() - frappe.db.set_value("Google Drive", None, "backup_folder_id", folder.get("id")) + frappe.db.set_single_value("Google Drive", "backup_folder_id", folder.get("id")) frappe.db.commit() except HttpError as e: frappe.throw( @@ -120,7 +119,7 @@ def check_for_folder_in_google_drive(): for f in google_drive_folders.get("files"): if f.get("name") == account.backup_folder_name: - frappe.db.set_value("Google Drive", None, "backup_folder_id", f.get("id")) + frappe.db.set_single_value("Google Drive", "backup_folder_id", f.get("id")) frappe.db.commit() backup_folder_exists = True break @@ -186,7 +185,7 @@ def upload_system_backup_to_google_drive(): send_email(False, "Google Drive", "Google Drive", "email", error_status=e) set_progress(3, "Uploading successful.") - frappe.db.set_value("Google Drive", None, "last_backup_on", frappe.utils.now_datetime()) + frappe.db.set_single_value("Google Drive", "last_backup_on", frappe.utils.now_datetime()) send_email(True, "Google Drive", "Google Drive", "email") return _("Google Drive Backup Successful.") diff --git a/frappe/integrations/doctype/google_settings/test_google_settings.py b/frappe/integrations/doctype/google_settings/test_google_settings.py index d67d7cf40b..d4bb830779 100644 --- a/frappe/integrations/doctype/google_settings/test_google_settings.py +++ b/frappe/integrations/doctype/google_settings/test_google_settings.py @@ -17,24 +17,24 @@ class TestGoogleSettings(FrappeTestCase): def test_picker_disabled(self): """Google Drive Picker should be disabled if it is not enabled in Google Settings.""" - frappe.db.set_value("Google Settings", None, "enable", 1) - frappe.db.set_value("Google Settings", None, "google_drive_picker_enabled", 0) + frappe.db.set_single_value("Google Settings", "enable", 1) + frappe.db.set_single_value("Google Settings", "google_drive_picker_enabled", 0) settings = get_file_picker_settings() self.assertEqual(settings, {}) def test_google_disabled(self): """Google Drive Picker should be disabled if Google integration is not enabled.""" - frappe.db.set_value("Google Settings", None, "enable", 0) - frappe.db.set_value("Google Settings", None, "google_drive_picker_enabled", 1) + frappe.db.set_single_value("Google Settings", "enable", 0) + frappe.db.set_single_value("Google Settings", "google_drive_picker_enabled", 1) settings = get_file_picker_settings() self.assertEqual(settings, {}) def test_picker_enabled(self): """If picker is enabled, get_file_picker_settings should return the credentials.""" - frappe.db.set_value("Google Settings", None, "enable", 1) - frappe.db.set_value("Google Settings", None, "google_drive_picker_enabled", 1) + frappe.db.set_single_value("Google Settings", "enable", 1) + frappe.db.set_single_value("Google Settings", "google_drive_picker_enabled", 1) settings = get_file_picker_settings() self.assertEqual(True, settings.get("enabled", False)) diff --git a/frappe/patches/v10_0/set_default_locking_time.py b/frappe/patches/v10_0/set_default_locking_time.py index 7c9f3ceff1..4eb120873d 100644 --- a/frappe/patches/v10_0/set_default_locking_time.py +++ b/frappe/patches/v10_0/set_default_locking_time.py @@ -6,4 +6,4 @@ import frappe def execute(): frappe.reload_doc("core", "doctype", "system_settings") - frappe.db.set_value("System Settings", None, "allow_login_after_fail", 60) + frappe.db.set_single_value("System Settings", "allow_login_after_fail", 60) diff --git a/frappe/patches/v11_0/set_dropbox_file_backup.py b/frappe/patches/v11_0/set_dropbox_file_backup.py index 396491e8b3..5770c55bf4 100644 --- a/frappe/patches/v11_0/set_dropbox_file_backup.py +++ b/frappe/patches/v11_0/set_dropbox_file_backup.py @@ -6,4 +6,4 @@ def execute(): frappe.reload_doctype("Dropbox Settings") check_dropbox_enabled = cint(frappe.db.get_single_value("Dropbox Settings", "enabled")) if check_dropbox_enabled == 1: - frappe.db.set_value("Dropbox Settings", None, "file_backup", 1) + frappe.db.set_single_value("Dropbox Settings", "file_backup", 1) diff --git a/frappe/patches/v12_0/set_default_password_reset_limit.py b/frappe/patches/v12_0/set_default_password_reset_limit.py index 9e29e75c36..98a397b606 100644 --- a/frappe/patches/v12_0/set_default_password_reset_limit.py +++ b/frappe/patches/v12_0/set_default_password_reset_limit.py @@ -6,4 +6,4 @@ import frappe def execute(): frappe.reload_doc("core", "doctype", "system_settings", force=1) - frappe.db.set_value("System Settings", None, "password_reset_limit", 3) + frappe.db.set_single_value("System Settings", "password_reset_limit", 3) diff --git a/frappe/patches/v13_0/set_first_day_of_the_week.py b/frappe/patches/v13_0/set_first_day_of_the_week.py index 165ec3c42b..a48ca09672 100644 --- a/frappe/patches/v13_0/set_first_day_of_the_week.py +++ b/frappe/patches/v13_0/set_first_day_of_the_week.py @@ -5,4 +5,4 @@ def execute(): frappe.reload_doctype("System Settings") # setting first_day_of_the_week value as "Monday" to avoid breaking change # because before the configuration was introduced, system used to consider "Monday" as start of the week - frappe.db.set_value("System Settings", "System Settings", "first_day_of_the_week", "Monday") + frappe.db.set_single_value("System Settings", "first_day_of_the_week", "Monday") diff --git a/frappe/patches/v14_0/set_document_expiry_default.py b/frappe/patches/v14_0/set_document_expiry_default.py index 59a9db6c4d..0b193bcd9b 100644 --- a/frappe/patches/v14_0/set_document_expiry_default.py +++ b/frappe/patches/v14_0/set_document_expiry_default.py @@ -2,8 +2,7 @@ import frappe def execute(): - frappe.db.set_value( - "System Settings", + frappe.db.set_single_value( "System Settings", {"document_share_key_expiry": 30, "allow_older_web_view_links": 1}, ) diff --git a/frappe/patches/v14_0/update_auto_account_deletion_duration.py b/frappe/patches/v14_0/update_auto_account_deletion_duration.py index 6b01816092..ed7b3f21fa 100644 --- a/frappe/patches/v14_0/update_auto_account_deletion_duration.py +++ b/frappe/patches/v14_0/update_auto_account_deletion_duration.py @@ -3,4 +3,4 @@ import frappe def execute(): days = frappe.db.get_single_value("Website Settings", "auto_account_deletion") - frappe.db.set_value("Website Settings", None, "auto_account_deletion", days * 24) + frappe.db.set_single_value("Website Settings", "auto_account_deletion", days * 24) diff --git a/frappe/public/js/frappe/list/list_view_select.js b/frappe/public/js/frappe/list/list_view_select.js index 5265ace340..daa16432b5 100644 --- a/frappe/public/js/frappe/list/list_view_select.js +++ b/frappe/public/js/frappe/list/list_view_select.js @@ -191,12 +191,16 @@ frappe.views.ListViewSelect = class ListViewSelect { ); }); - this.page.add_custom_menu_item( - kanban_switcher, - __("Create New Kanban Board"), - () => frappe.views.KanbanView.show_kanban_dialog(this.doctype), - true - ); + let perms = this.list_view.board_perms; + let can_create = perms ? perms.create : true; + if (can_create) { + this.page.add_custom_menu_item( + kanban_switcher, + __("Create New Kanban Board"), + () => frappe.views.KanbanView.show_kanban_dialog(this.doctype), + true + ); + } } get_page_name() { diff --git a/frappe/public/js/frappe/views/kanban/kanban_board.bundle.js b/frappe/public/js/frappe/views/kanban/kanban_board.bundle.js index 11e7fba198..1810101820 100644 --- a/frappe/public/js/frappe/views/kanban/kanban_board.bundle.js +++ b/frappe/public/js/frappe/views/kanban/kanban_board.bundle.js @@ -297,6 +297,7 @@ frappe.provide("frappe.views"); self.wrapper = opts.wrapper; self.cur_list = opts.cur_list; self.board_name = opts.board_name; + self.board_perms = self.cur_list.board_perms; self.update = function (cards) { // update cards internally @@ -325,6 +326,7 @@ frappe.provide("frappe.views"); store.watch((state, getters) => { return state.empty_state; }, show_empty_state); + store.dispatch("update_order"); } @@ -346,7 +348,7 @@ frappe.provide("frappe.views"); var columns = store.state.columns; columns.filter(is_active_column).map(function (col) { - frappe.views.KanbanBoardColumn(col, self.$kanban_board); + frappe.views.KanbanBoardColumn(col, self.$kanban_board, self.board_perms); }); } @@ -356,6 +358,9 @@ frappe.provide("frappe.views"); } function setup_sortable() { + // If no write access to board, editing board (by dragging column) should be blocked + if (!self.board_perms.write) return; + var sortable = new Sortable(self.$kanban_board.get(0), { group: "columns", animation: 150, @@ -371,6 +376,12 @@ frappe.provide("frappe.views"); } function bind_add_column() { + if (!self.board_perms.write) { + // If no write access to board, editing board (by adding column) should be blocked + self.$kanban_board.find(".add-new-column").remove(); + return; + } + var $add_new_column = self.$kanban_board.find(".add-new-column"), $compose_column = $add_new_column.find(".compose-column"), $compose_column_form = $add_new_column.find(".compose-column-form").hide(); @@ -512,7 +523,7 @@ frappe.provide("frappe.views"); return self; }; - frappe.views.KanbanBoardColumn = function (column, wrapper) { + frappe.views.KanbanBoardColumn = function (column, wrapper, board_perms) { var self = {}; var filtered_cards = []; @@ -535,6 +546,7 @@ frappe.provide("frappe.views"); indicator: frappe.scrub(column.indicator, "-"), }) ).appendTo(wrapper); + // add task, archive self.$kanban_cards = self.$kanban_column.find(".kanban-cards"); } @@ -565,6 +577,9 @@ frappe.provide("frappe.views"); } function setup_sortable() { + // Block card dragging/record editing without 'write' access to reference doctype + if (!frappe.model.can_write(store.state.doctype)) return; + Sortable.create(self.$kanban_cards.get(0), { group: "cards", animation: 150, @@ -598,6 +613,14 @@ frappe.provide("frappe.views"); var $wrapper = self.$kanban_column; var $btn_add = $wrapper.find(".add-card"); var $new_card_area = $wrapper.find(".new-card-area"); + + if (!frappe.model.can_create(store.state.doctype)) { + // Block record/card creation without 'create' access to reference doctype + $btn_add.remove(); + $new_card_area.remove(); + return; + } + var $textarea = $new_card_area.find("textarea"); //Add card button @@ -639,6 +662,12 @@ frappe.provide("frappe.views"); } function bind_options() { + if (!board_perms.write) { + // If no write access to board, column options should be hidden + self.$kanban_column.find(".column-options").remove(); + return; + } + self.$kanban_column .find(".column-options .dropdown-menu") .on("click", "[data-action]", function () { @@ -652,6 +681,7 @@ frappe.provide("frappe.views"); store.dispatch("set_indicator", { column, color }); } }); + get_column_indicators(function (indicators) { let html = `