diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 03efd1d30d..e87590b976 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -37,3 +37,6 @@ c0c5b2ebdddbe8898ce2d5e5365f4931ff73b6bf # minor formatting fix in `user.py` f223bc02490902dfcc32892058f13f343d51fbaf + +# frappe.cache() -> frappe.cache +fa6dc03cc87ad74e11609e7373078366fdcb3e1b diff --git a/.github/helper/install.sh b/.github/helper/install.sh index 39880e35e7..5a4d341a9b 100644 --- a/.github/helper/install.sh +++ b/.github/helper/install.sh @@ -54,7 +54,9 @@ fi echo "Starting Bench..." -bench start &> bench_start.log & +export FRAPPE_TUNE_GC=True + +bench start &> ~/frappe-bench/bench_start.log & if [ "$TYPE" == "server" ] then diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml index c563f9e43f..481041ed68 100644 --- a/.github/workflows/linters.yml +++ b/.github/workflows/linters.yml @@ -25,7 +25,7 @@ jobs: fetch-depth: 200 - uses: actions/setup-node@v3 with: - node-version: 16 + node-version: 18 check-latest: true - name: Check commit titles diff --git a/.github/workflows/on_release.yml b/.github/workflows/on_release.yml index 851b5b1d6a..c17a7c6639 100644 --- a/.github/workflows/on_release.yml +++ b/.github/workflows/on_release.yml @@ -22,7 +22,7 @@ jobs: - uses: actions/setup-node@v3 with: - node-version: 16 + node-version: 18 - uses: actions/setup-python@v4 with: diff --git a/.github/workflows/patch-mariadb-tests.yml b/.github/workflows/patch-mariadb-tests.yml index 4b487d2aea..87cd530538 100644 --- a/.github/workflows/patch-mariadb-tests.yml +++ b/.github/workflows/patch-mariadb-tests.yml @@ -62,14 +62,16 @@ jobs: fi - name: Setup Python - uses: "gabrielfalcao/pyenv-action@v10" + uses: actions/setup-python@v4 with: - versions: 3.10:latest, 3.7:latest + python-version: | + 3.7 + 3.10 - name: Setup Node uses: actions/setup-node@v3 with: - node-version: 16 + node-version: 18 check-latest: true - name: Add to Hosts @@ -100,7 +102,6 @@ jobs: run: | bash ${GITHUB_WORKSPACE}/.github/helper/install_dependencies.sh pip install frappe-bench - pyenv global $(pyenv versions | grep '3.10') bash ${GITHUB_WORKSPACE}/.github/helper/install.sh env: BEFORE: ${{ env.GITHUB_EVENT_PATH.before }} @@ -120,26 +121,39 @@ jobs: function update_to_version() { version=$1 + py=$2 + branch_name="version-$version-hotfix" echo "Updating to v$version" git fetch --depth 1 upstream $branch_name:$branch_name git checkout -q -f $branch_name - pip install -U frappe-bench + pgrep honcho | xargs kill rm -rf ~/frappe-bench/env - bench -v setup env + bench -v setup env --python $py + bench start &> ~/frappe-bench/bench_start.log & + bench --site test_site migrate } - pyenv global $(pyenv versions | grep '3.7') - update_to_version 12 - update_to_version 13 + update_to_version 12 python3.7 + update_to_version 13 python3.7 - pyenv global $(pyenv versions | grep '3.10') - update_to_version 14 + update_to_version 14 python3.10 echo "Updating to last commit" - git checkout -q -f "$GITHUB_SHA" rm -rf ~/frappe-bench/env + git checkout -q -f "$GITHUB_SHA" bench -v setup env bench --site test_site migrate + + - name: Show bench output + if: ${{ always() }} + run: | + cd ~/frappe-bench + cat bench_start.log || true + cd logs + for f in ./*.log*; do + echo "Printing log: $f"; + cat $f + done diff --git a/.github/workflows/publish-assets-develop.yml b/.github/workflows/publish-assets-develop.yml index 4feaebe15d..f42c3bc55c 100644 --- a/.github/workflows/publish-assets-develop.yml +++ b/.github/workflows/publish-assets-develop.yml @@ -16,7 +16,7 @@ jobs: path: 'frappe' - uses: actions/setup-node@v3 with: - node-version: 16 + node-version: 18 - uses: actions/setup-python@v4 with: python-version: '3.11' diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml index 3b76da1973..f5eac8e380 100644 --- a/.github/workflows/server-tests.yml +++ b/.github/workflows/server-tests.yml @@ -90,7 +90,7 @@ jobs: - uses: actions/setup-node@v3 with: - node-version: 16 + node-version: 18 check-latest: true - name: Add to Hosts @@ -136,6 +136,10 @@ jobs: BUILD_NUMBER: ${{ matrix.container }} TOTAL_BUILDS: 2 + - name: Show bench output + if: ${{ always() }} + run: cat ~/frappe-bench/bench_start.log || true + - name: Upload coverage data uses: actions/upload-artifact@v3 with: diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 1b88bc73ce..bea00748e9 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -78,7 +78,7 @@ jobs: - uses: actions/setup-node@v3 with: - node-version: 16 + node-version: 18 check-latest: true - name: Add to Hosts diff --git a/.semgrepignore b/.semgrepignore new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cypress.config.js b/cypress.config.js index bfd0bc0025..2fdf10ca14 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -8,6 +8,8 @@ module.exports = defineConfig({ pageLoadTimeout: 15000, video: true, videoUploadOnPasses: false, + viewportHeight: 960, + viewportWidth: 1400, retries: { runMode: 2, openMode: 2, diff --git a/cypress/integration/awesome_bar.js b/cypress/integration/awesome_bar.js index 71e5e498cf..ecf8dcc718 100644 --- a/cypress/integration/awesome_bar.js +++ b/cypress/integration/awesome_bar.js @@ -7,50 +7,41 @@ context("Awesome Bar", () => { beforeEach(() => { cy.get(".navbar .navbar-home").click(); - cy.findByPlaceholderText("Search or type a command (Ctrl + G)").clear(); + cy.findByPlaceholderText("Search or type a command (Ctrl + G)").as("awesome_bar"); + cy.get("@awesome_bar").type("{selectall}"); }); it("navigates to doctype list", () => { - cy.findByPlaceholderText("Search or type a command (Ctrl + G)").type("todo", { - delay: 700, - }); + cy.get("@awesome_bar").type("todo"); + cy.wait(100); cy.get(".awesomplete").findByRole("listbox").should("be.visible"); - cy.findByPlaceholderText("Search or type a command (Ctrl + G)").type("{enter}", { - delay: 700, - }); - + cy.get("@awesome_bar").type("{enter}"); cy.get(".title-text").should("contain", "To Do"); - cy.location("pathname").should("eq", "/app/todo"); }); it("find text in doctype list", () => { - cy.findByPlaceholderText("Search or type a command (Ctrl + G)").type( - "test in todo{enter}", - { delay: 700 } - ); - + cy.get("@awesome_bar").type("test in todo"); + cy.wait(100); + cy.get("@awesome_bar").type("{enter}"); cy.get(".title-text").should("contain", "To Do"); - - cy.findByPlaceholderText("ID").should("have.value", "%test%"); + cy.wait(200); + const name_filter = cy.findByPlaceholderText("ID"); + name_filter.should("have.value", "%test%"); cy.clear_filters(); }); it("navigates to new form", () => { - cy.findByPlaceholderText("Search or type a command (Ctrl + G)").type( - "new blog post{enter}", - { delay: 700 } - ); - + cy.get("@awesome_bar").type("new blog post"); + cy.wait(100); + cy.get("@awesome_bar").type("{enter}"); cy.get(".title-text:visible").should("have.text", "New Blog Post"); }); it("calculates math expressions", () => { - cy.findByPlaceholderText("Search or type a command (Ctrl + G)").type( - "55 + 32{downarrow}{enter}", - { delay: 700 } - ); - + cy.get("@awesome_bar").type("55 + 32"); + cy.wait(100); + cy.get("@awesome_bar").type("{downarrow}{enter}"); cy.get(".modal-title").should("contain", "Result"); cy.get(".msgprint").should("contain", "55 + 32 = 87"); }); diff --git a/cypress/integration/control_currency.js b/cypress/integration/control_currency.js new file mode 100644 index 0000000000..5e6db86036 --- /dev/null +++ b/cypress/integration/control_currency.js @@ -0,0 +1,74 @@ +context("Control Currency", () => { + const fieldname = "currency_field"; + + before(() => { + cy.login(); + cy.visit("/app/website"); + }); + + function get_dialog_with_currency(df_options = {}) { + return cy.dialog({ + title: "Currency Check", + fields: [ + { + fieldname: fieldname, + fieldtype: "Currency", + Label: "Currency", + ...df_options, + }, + ], + }); + } + + it("check value changes", () => { + const TEST_CASES = [ + { + input: "10.101", + df_options: { precision: 1 }, + blur_expected: "10.1", + }, + { + input: "10.101", + df_options: { precision: "3" }, + blur_expected: "10.101", + }, + { + input: "10.101", + df_options: { precision: "" }, // default assumed to be 2; + blur_expected: "10.10", + }, + { + input: "10.101", + df_options: { precision: "0" }, + blur_expected: "10", + }, + { + input: "10.101", + df_options: { precision: 0 }, + blur_expected: "10", + }, + { + input: "10.101", + df_options: { precision: "" }, + blur_expected: "10.1", + default_precision: 1, + }, + ]; + + TEST_CASES.forEach((test_case) => { + cy.window() + .its("frappe") + .then((frappe) => { + frappe.boot.sysdefaults.currency = test_case.currency; + frappe.boot.sysdefaults.currency_precision = test_case.default_precision ?? 2; + }); + + get_dialog_with_currency(test_case.df_options).as("dialog"); + cy.get_field(fieldname, "Currency").clear(); + cy.wait(300); + cy.fill_field(fieldname, test_case.input, "Currency").blur(); + cy.get_field(fieldname, "Currency").should("have.value", test_case.blur_expected); + cy.hide_dialog(); + }); + }); +}); diff --git a/cypress/integration/control_icon.js b/cypress/integration/control_icon.js index a965ed0f9e..406e9f1162 100644 --- a/cypress/integration/control_icon.js +++ b/cypress/integration/control_icon.js @@ -42,14 +42,14 @@ context("Control Icon", () => { it("search for icon and clear search input", () => { let search_text = "ed"; - cy.get(".icon-picker").findByRole("searchbox").click().type(search_text); + cy.get(".icon-picker").get(".search-icons > input").click().type(search_text); cy.get(".icon-section .icon-wrapper:not(.hidden)").then((i) => { cy.get(`.icon-section .icon-wrapper[id*='${search_text}']`).then((icons) => { expect(i.length).to.equal(icons.length); }); }); - cy.get(".icon-picker").findByRole("searchbox").clear().blur(); + cy.get(".icon-picker").get(".search-icons > input").clear().blur(); cy.get(".icon-section .icon-wrapper").should("not.have.class", "hidden"); }); }); diff --git a/cypress/integration/control_link.js b/cypress/integration/control_link.js index d3462492f6..0746f4460e 100644 --- a/cypress/integration/control_link.js +++ b/cypress/integration/control_link.js @@ -133,8 +133,7 @@ context("Control Link", () => { true ); - cy.clear_cache(); - cy.wait(500); + cy.reload(); get_dialog_with_link().as("dialog"); cy.window() @@ -177,7 +176,7 @@ context("Control Link", () => { cy.intercept("POST", "/api/method/frappe.client.validate_link").as("validate_link"); cy.get(".frappe-control[data-fieldname=assigned_by] input").focus().as("input"); - cy.get("@input").type(cy.config("testUser"), { delay: 100 }).blur(); + cy.get("@input").clear().type(cy.config("testUser"), { delay: 300 }).blur(); cy.wait("@validate_link"); cy.get(".frappe-control[data-fieldname=assigned_by_full_name] .control-value").should( "contain", diff --git a/cypress/integration/control_phone.js b/cypress/integration/control_phone.js index b56343c2d8..b5b29fe758 100644 --- a/cypress/integration/control_phone.js +++ b/cypress/integration/control_phone.js @@ -47,7 +47,7 @@ context("Control Phone", () => { it("case insensitive search for country and clear search", () => { let search_text = "india"; cy.get(".selected-phone").click().first(); - cy.get(".phone-picker").findByRole("searchbox").click().type(search_text); + cy.get(".phone-picker").get(".search-phones").click().type(search_text); cy.get(".phone-section .phone-wrapper:not(.hidden)").then((i) => { cy.get(`.phone-section .phone-wrapper[id*="${search_text.toLowerCase()}"]`).then( (countries) => { @@ -56,7 +56,7 @@ context("Control Phone", () => { ); }); - cy.get(".phone-picker").findByRole("searchbox").clear().blur(); + cy.get(".phone-picker").get(".search-phones").clear(); cy.get(".phone-section .phone-wrapper").should("not.have.class", "hidden"); }); diff --git a/cypress/integration/folder_navigation.js b/cypress/integration/folder_navigation.js index ba65454ef6..4a32c16516 100644 --- a/cypress/integration/folder_navigation.js +++ b/cypress/integration/folder_navigation.js @@ -11,9 +11,9 @@ context("Folder Navigation", () => { cy.click_filter_button(); cy.get(".filter-action-buttons > .text-muted").findByText("+ Add a Filter").click(); cy.get(".fieldname-select-area > .awesomplete > .form-control:last").type("Fol{enter}"); - cy.get( - ".filter-field > .form-group > .link-field > .awesomplete > .input-with-feedback" - ).type("Home{enter}"); + cy.get(".filter-field > .form-group > .link-field > .awesomplete > .input-with-feedback") + .first() + .type("Home{enter}"); cy.get(".filter-action-buttons > div > .btn-primary").findByText("Apply Filters").click(); //Adding folder (Test Folder) @@ -24,6 +24,7 @@ context("Folder Navigation", () => { it("Navigating the nested folders, checking if the URL formed is correct, checking if the added content in the child folder is correct", () => { //Navigating inside the Attachments folder + cy.clear_filters(); cy.wait(500); cy.get('[title="Attachments"] > span').click(); diff --git a/cypress/integration/form.js b/cypress/integration/form.js index 8186647a14..cdd6d7e9bd 100644 --- a/cypress/integration/form.js +++ b/cypress/integration/form.js @@ -59,11 +59,13 @@ context("Form", () => { .blur(); cy.click_listview_row_item_with_text("Test Form Contact 3"); + cy.scrollTo(0); cy.get("#page-Contact .page-head").findByTitle("Test Form Contact 3").should("exist"); cy.get(".prev-doc").should("be.visible").click(); cy.get(".msgprint-dialog .modal-body").contains("No further records").should("be.visible"); cy.hide_dialog(); + cy.scrollTo(0); cy.get("#page-Contact .page-head").findByTitle("Test Form Contact 3").should("exist"); cy.get(".next-doc").should("be.visible").click(); cy.get(".msgprint-dialog .modal-body").contains("No further records").should("be.visible"); diff --git a/cypress/integration/kanban.js b/cypress/integration/kanban.js index f14c991c7c..12c5e7e8bd 100644 --- a/cypress/integration/kanban.js +++ b/cypress/integration/kanban.js @@ -100,15 +100,15 @@ context("Kanban Board", () => { it("Checks if Kanban Board edits are blocked for non-System Manager and non-owner of the Board", () => { cy.switch_to_user("Administrator"); - const noSystemManager = "nosysmanager@example.com"; + const not_system_manager = "nosysmanager@example.com"; cy.call("frappe.tests.ui_test_helpers.create_test_user", { - username: noSystemManager, + username: not_system_manager, }); - cy.remove_role(noSystemManager, "System Manager"); + cy.remove_role(not_system_manager, "System Manager"); cy.call("frappe.tests.ui_test_helpers.create_todo", { description: "Frappe User ToDo" }); cy.call("frappe.tests.ui_test_helpers.create_admin_kanban"); - cy.switch_to_user(noSystemManager); + cy.switch_to_user(not_system_manager); cy.visit("/app/todo/view/kanban/Admin Kanban"); @@ -125,7 +125,7 @@ context("Kanban Board", () => { cy.get(".kanban .column-options").should("have.length", 0); cy.switch_to_user("Administrator"); - cy.call("frappe.client.delete", { doctype: "User", name: noSystemManager }); + cy.call("frappe.client.delete", { doctype: "User", name: not_system_manager }); }); after(() => { diff --git a/cypress/integration/list_view.js b/cypress/integration/list_view.js index 3fa0758f0c..b07f18edc2 100644 --- a/cypress/integration/list_view.js +++ b/cypress/integration/list_view.js @@ -13,15 +13,8 @@ context("List View", () => { it("Keep checkbox checked after Refresh", { scrollBehavior: false }, () => { cy.go_to_list("ToDo"); cy.clear_filters(); - cy.get(".list-row-container .list-row-checkbox").click({ - multiple: true, - force: true, - }); - cy.get(".actions-btn-group button").contains("Actions").should("be.visible"); - cy.intercept("/api/method/frappe.desk.reportview.get").as("list-refresh"); - cy.wait(3000); // wait before you hit another refresh - cy.get('button[data-original-title="Refresh"]').click(); - cy.wait("@list-refresh"); + cy.get(".list-header-subject > .list-subject > .list-check-all").click(); + cy.get("button[data-original-title='Refresh']").click(); cy.get(".list-row-container .list-row-checkbox:checked").should("be.visible"); }); @@ -39,11 +32,8 @@ context("List View", () => { ]; cy.go_to_list("ToDo"); cy.clear_filters(); - cy.get('.list-row-container:contains("Pending") .list-row-checkbox').click({ - multiple: true, - force: true, - }); - cy.get(".actions-btn-group button").contains("Actions").should("be.visible").click(); + cy.get(".list-header-subject > .list-subject > .list-check-all").click(); + cy.findByRole("button", { name: "Actions" }).click(); cy.get(".dropdown-menu li:visible .dropdown-item") .should("have.length", 9) .each((el, index) => { @@ -56,8 +46,7 @@ context("List View", () => { }).as("bulk-approval"); cy.wrap(elements).contains("Approve").click(); cy.wait("@bulk-approval"); - cy.wait(300); - cy.get_open_dialog().find(".btn-modal-close").click(); + cy.hide_dialog(); cy.reload(); cy.clear_filters(); cy.get(".list-row-container:visible").should("contain", "Approved"); diff --git a/cypress/integration/navigation.js b/cypress/integration/navigation.js index cf1b5dc89d..5961702ba5 100644 --- a/cypress/integration/navigation.js +++ b/cypress/integration/navigation.js @@ -1,21 +1,26 @@ context("Navigation", () => { before(() => { + cy.visit("/login"); cy.login(); + cy.visit("/app/website"); }); it("Navigate to route with hash in document name", () => { - cy.insert_doc("ToDo", { - __newname: "ABC#123", - description: "Test this", - ignore_duplicate: true, - }); - cy.visit("/app/todo/ABC#123"); + cy.insert_doc( + "ToDo", + { + __newname: "ABC#123", + description: "Test this", + }, + true + ); + cy.visit(`/app/todo/${encodeURIComponent("ABC#123")}`); cy.title().should("eq", "Test this - ABC#123"); cy.get_field("description", "Text Editor").contains("Test this"); cy.go("back"); cy.title().should("eq", "Website"); }); - it.only("Navigate to previous page after login", () => { + it("Navigate to previous page after login", () => { cy.visit("/app/todo"); cy.get(".page-head").findByTitle("To Do").should("be.visible"); cy.clear_filters(); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 4b44a24598..23b03549fa 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -34,7 +34,7 @@ Cypress.Commands.add("login", (email, password) => { if (!password) { password = Cypress.env("adminPassword"); } - cy.request({ + return cy.request({ url: "/api/method/login", method: "POST", body: { @@ -373,7 +373,9 @@ Cypress.Commands.add("update_doc", (doctype, docname, args) => { Cypress.Commands.add("switch_to_user", (user) => { cy.call("logout"); + cy.wait(200); cy.login(user); + cy.reload(); }); Cypress.Commands.add("add_role", (user, role) => { diff --git a/esbuild/esbuild.js b/esbuild/esbuild.js index 4804f0e25f..1476db3c20 100644 --- a/esbuild/esbuild.js +++ b/esbuild/esbuild.js @@ -60,6 +60,11 @@ const argv = yargs type: "boolean", description: "Run build command for apps", }) + .option("save-metafiles", { + type: "boolean", + description: + "Saves esbuild metafiles for built assets. Useful for analyzing bundle size. More info: https://esbuild.github.io/api/#metafile", + }) .example("node esbuild --apps frappe,erpnext", "Run build only for frappe and erpnext") .example( "node esbuild --files frappe/website.bundle.js,frappe/desk.bundle.js", @@ -89,7 +94,7 @@ execute() .then(() => RUN_BUILD_COMMAND && run_build_command_for_apps(APPS)) .catch((e) => { console.error(e); - throw e; + process.exit(1); }); if (WATCH_MODE) { @@ -401,6 +406,13 @@ async function write_assets_json(metafile) { await fs.promises.writeFile(assets_json_path, JSON.stringify(new_assets_json, null, 4)); await update_assets_json_in_cache(); + if (argv["save-metafiles"]) { + // use current timestamp in readable formate as a suffix for filename + let current_timestamp = new Date().getTime(); + const metafile_name = `meta-${current_timestamp}.json`; + await fs.promises.writeFile(`${metafile_name}`, JSON.stringify(metafile)); + log(`Saved metafile as ${metafile_name}`); + } return { new_assets_json, prev_assets_json, @@ -446,9 +458,9 @@ 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_socketio"); + let subscriber = get_redis_subscriber("redis_queue"); subscriber.on("error", (_) => { - log_warn("Cannot connect to redis_socketio for browser events"); + log_warn("Cannot connect to redis_queue for browser events"); }); let payload = null; @@ -482,9 +494,9 @@ async function notify_redis({ error, success, changed_files }) { } function open_in_editor() { - let subscriber = get_redis_subscriber("redis_socketio"); + let subscriber = get_redis_subscriber("redis_queue"); subscriber.on("error", (_) => { - log_warn("Cannot connect to redis_socketio for open_in_editor events"); + log_warn("Cannot connect to redis_queue for open_in_editor events"); }); subscriber.on("message", (event, file) => { if (event === "open_in_editor") { diff --git a/frappe/__init__.py b/frappe/__init__.py index 84ac41ff66..998d881a13 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -11,6 +11,7 @@ be used to build database driven apps. Read the documentation: https://frappeframework.com/docs """ import functools +import gc import importlib import inspect import json @@ -57,6 +58,7 @@ re._MAXCACHE = ( 50 # reduced from default 512 given we are already maintaining this on parent worker ) +_tune_gc = bool(os.environ.get("FRAPPE_TUNE_GC", False)) if _dev_server: warnings.simplefilter("always", DeprecationWarning) @@ -380,7 +382,7 @@ def errprint(msg: str) -> None: def print_sql(enable: bool = True) -> None: - return cache().set_value("flag_print_sql", enable) + return cache.set_value("flag_print_sql", enable) def log(msg: str) -> None: @@ -925,7 +927,6 @@ def has_permission( ptype="read", doc=None, user=None, - verbose=False, throw=False, *, parent_doctype=None, @@ -938,7 +939,6 @@ def has_permission( :param ptype: Permission type (`read`, `write`, `create`, `submit`, `cancel`, `amend`). Default: `read`. :param doc: [optional] Checks User permissions for given doc. :param user: [optional] Check for given user. Default: current user. - :param verbose: DEPRECATED, will be removed in a future release. :param parent_doctype: Required when checking permission for a child DocType (unless doc is specified). """ import frappe.permissions @@ -1016,7 +1016,7 @@ def is_table(doctype: str) -> bool: def get_tables(): return db.get_values("DocType", filters={"istable": 1}, order_by=None, pluck=True) - tables = cache().get_value("is_table", get_tables) + tables = cache.get_value("is_table", get_tables) return doctype in tables @@ -1043,7 +1043,7 @@ def generate_hash(txt: str | None = None, length: int = 56) -> str: def reset_metadata_version(): """Reset `metadata_version` (Client (Javascript) build ID) hash.""" v = generate_hash() - cache().set_value("metadata_version", v) + cache.set_value("metadata_version", v) return v @@ -1079,7 +1079,7 @@ def set_value(doctype, docname, fieldname, value=None): def get_cached_doc(*args, **kwargs) -> "Document": - if (key := can_cache_doc(args)) and (doc := cache().get_value(key)): + if (key := can_cache_doc(args)) and (doc := cache.get_value(key)): return doc # Not found in cache, fetch from DB @@ -1095,7 +1095,7 @@ def get_cached_doc(*args, **kwargs) -> "Document": def _set_document_in_cache(key: str, doc: "Document") -> None: - cache().set_value(key, doc) + cache.set_value(key, doc) def can_cache_doc(args) -> str | None: @@ -1122,9 +1122,9 @@ def get_document_cache_key(doctype: str, name: str): def clear_document_cache(doctype: str, name: str | None = None) -> None: def clear_in_redis(): if name is not None: - cache().delete_value(get_document_cache_key(doctype, name)) + cache.delete_value(get_document_cache_key(doctype, name)) else: - cache().delete_keys(get_document_cache_key(doctype, "")) + cache.delete_keys(get_document_cache_key(doctype, "")) clear_in_redis() if hasattr(db, "after_commit"): @@ -1214,7 +1214,7 @@ def get_doc(*args, **kwargs): doc = frappe.model.document.get_doc(*args, **kwargs) # Replace cache if stale one exists - if (key := can_cache_doc(args)) and cache().exists(key): + if (key := can_cache_doc(args)) and cache.exists(key): _set_document_in_cache(key, doc) return doc @@ -1428,7 +1428,7 @@ def get_all_apps(with_internal_apps=True, sites_path=None): @request_cache -def get_installed_apps(sort=False, frappe_last=False, *, _ensure_on_bench=False): +def get_installed_apps(*, _ensure_on_bench=False): """ Get list of installed apps in current site. @@ -1436,8 +1436,6 @@ def get_installed_apps(sort=False, frappe_last=False, *, _ensure_on_bench=False) :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. """ - from frappe.utils.deprecations import deprecation_warning - if getattr(flags, "in_install_db", True): return [] @@ -1446,23 +1444,10 @@ def get_installed_apps(sort=False, frappe_last=False, *, _ensure_on_bench=False) installed = json.loads(db.get_global("installed_apps") or "[]") - if sort: - if not local.all_apps: - local.all_apps = cache().get_value("all_apps", get_all_apps) - - deprecation_warning("`sort` argument is deprecated and will be removed in v15.") - installed = [app for app in local.all_apps if app in installed] - if _ensure_on_bench: - all_apps = cache().get_value("all_apps", get_all_apps) + all_apps = cache.get_value("all_apps", get_all_apps) installed = [app for app in installed if app in all_apps] - if frappe_last: - deprecation_warning("`frappe_last` argument is deprecated and will be removed in v15.") - if "frappe" in installed: - installed.remove("frappe") - installed.append("frappe") - return installed @@ -1525,7 +1510,7 @@ def get_hooks( if conf.developer_mode: hooks = _dict(_load_app_hooks()) else: - hooks = _dict(cache().get_value("app_hooks", _load_app_hooks)) + hooks = _dict(cache.get_value("app_hooks", _load_app_hooks)) if hook: return hooks.get(hook, ([] if default == "_KEEP_DEFAULT_LIST" else default)) @@ -1555,11 +1540,9 @@ def append_hook(target, key, value): def setup_module_map(): """Rebuild map of all modules (internal).""" - _cache = cache() - if conf.db_name: - local.app_modules = _cache.get_value("app_modules") - local.module_app = _cache.get_value("module_app") + local.app_modules = cache.get_value("app_modules") + local.module_app = cache.get_value("module_app") if not (local.app_modules and local.module_app): local.module_app, local.app_modules = {}, {} @@ -1571,8 +1554,8 @@ def setup_module_map(): local.app_modules[app].append(module) if conf.db_name: - _cache.set_value("app_modules", local.app_modules) - _cache.set_value("module_app", local.module_app) + cache.set_value("app_modules", local.app_modules) + cache.set_value("module_app", local.module_app) def get_file_items(path, raise_not_found=False, ignore_empty_lines=True): @@ -1861,7 +1844,7 @@ def redirect_to_message(title, html, http_status_code=None, context=None, indica if indicator_color: message["context"].update({"indicator_color": indicator_color}) - cache().set_value(f"message_id:{message_id}", message, expires_in_sec=60) + cache.set_value(f"message_id:{message_id}", message, expires_in_sec=60) location = f"/message?id={message_id}" if not getattr(local, "is_ajax", False): @@ -2437,4 +2420,30 @@ def mock(type, size=1, locale="en"): return squashify(results) -from frappe.desk.search import validate_and_sanitize_search_inputs # noqa +def validate_and_sanitize_search_inputs(fn): + @functools.wraps(fn) + def wrapper(*args, **kwargs): + from frappe.desk.search import sanitize_searchfield + from frappe.utils import cint + + kwargs.update(dict(zip(fn.__code__.co_varnames, args))) + sanitize_searchfield(kwargs["searchfield"]) + kwargs["start"] = cint(kwargs["start"]) + kwargs["page_len"] = cint(kwargs["page_len"]) + + if kwargs["doctype"] and not db.exists("DocType", kwargs["doctype"]): + return [] + + return fn(**kwargs) + + return wrapper + + +if _tune_gc: + # generational GC gets triggered after certain allocs (g0) which is 700 by default. + # This number is quite small for frappe where a single query can potentially create 700+ + # objects easily. + # Bump this number higher, this will make GC less aggressive but that improves performance of + # everything else. + g0, g1, g2 = gc.get_threshold() # defaults are 700, 10, 10. + gc.set_threshold(g0 * 10, g1 * 2, g2 * 2) diff --git a/frappe/app.py b/frappe/app.py index 55855efaf9..5113c858a5 100644 --- a/frappe/app.py +++ b/frappe/app.py @@ -1,6 +1,7 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE +import gc import logging import os @@ -30,6 +31,30 @@ _site = None _sites_path = os.environ.get("SITES_PATH", ".") +# If gc.freeze is done then importing modules before forking allows us to share the memory +if frappe._tune_gc: + import frappe.boot + import frappe.client + import frappe.core.doctype.user.user + import frappe.database.mariadb.database # Load database related utils + import frappe.database.query + import frappe.desk.desktop # workspace + import frappe.model.db_query + import frappe.query_builder + import frappe.utils.background_jobs # Enqueue is very common + import frappe.utils.data # common utils + import frappe.utils.jinja # web page rendering + import frappe.utils.jinja_globals + import frappe.utils.redis_wrapper # Exact redis_wrapper + import frappe.utils.safe_exec + import frappe.utils.typing_validations # any whitelisted method uses this + import frappe.website.path_resolver # all the page types and resolver + import frappe.website.router # Website router + import frappe.website.website_generator # web page doctypes + +# end: module pre-loading + + @local_manager.middleware @Request.application def application(request: Request): @@ -157,6 +182,8 @@ def log_request(request, response): { "site": get_site_name(request.host), "remote_addr": getattr(request, "remote_addr", "NOTFOUND"), + "pid": os.getpid(), + "user": getattr(frappe.local.session, "user", "NOTFOUND"), "base_url": getattr(request, "base_url", "NOTFOUND"), "full_path": getattr(request, "full_path", "NOTFOUND"), "method": getattr(request, "method", "NOTFOUND"), @@ -392,3 +419,17 @@ def serve( use_evalex=not in_test_env, threaded=not no_threading, ) + + +# 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 +# generational GC which stores and mutates every python object: `PyGC_Head` +# +# Calling gc.freeze() moves all the objects imported so far into permanant generation and hence +# doesn't mutate `PyGC_Head` +# +# Refer to issue for more info: https://github.com/frappe/frappe/issues/18927 +if frappe._tune_gc: + gc.collect() # clean up any garbage created so far before freeze + gc.freeze() diff --git a/frappe/auth.py b/frappe/auth.py index f1cdac52bd..29c3e41694 100644 --- a/frappe/auth.py +++ b/frappe/auth.py @@ -188,10 +188,10 @@ class LoginManager: frappe.response["full_name"] = self.full_name # redirect information - redirect_to = frappe.cache().hget("redirect_after_login", self.user) + redirect_to = frappe.cache.hget("redirect_after_login", self.user) if redirect_to: frappe.local.response["redirect_to"] = redirect_to - frappe.cache().hdel("redirect_after_login", self.user) + frappe.cache.hdel("redirect_after_login", self.user) frappe.local.cookie_manager.set_cookie("full_name", self.full_name) frappe.local.cookie_manager.set_cookie("user_id", self.user) @@ -482,15 +482,15 @@ class LoginAttemptTracker: @property def login_failed_count(self): - return frappe.cache().hget("login_failed_count", self.user_name) + return frappe.cache.hget("login_failed_count", self.user_name) @login_failed_count.setter def login_failed_count(self, count): - frappe.cache().hset("login_failed_count", self.user_name, count) + frappe.cache.hset("login_failed_count", self.user_name, count) @login_failed_count.deleter def login_failed_count(self): - frappe.cache().hdel("login_failed_count", self.user_name) + frappe.cache.hdel("login_failed_count", self.user_name) @property def login_failed_time(self): @@ -498,15 +498,15 @@ class LoginAttemptTracker: For every user we track only First failed login attempt time within lock interval of time. """ - return frappe.cache().hget("login_failed_time", self.user_name) + return frappe.cache.hget("login_failed_time", self.user_name) @login_failed_time.setter def login_failed_time(self, timestamp): - frappe.cache().hset("login_failed_time", self.user_name, timestamp) + frappe.cache.hset("login_failed_time", self.user_name, timestamp) @login_failed_time.deleter def login_failed_time(self): - frappe.cache().hdel("login_failed_time", self.user_name) + frappe.cache.hdel("login_failed_time", self.user_name) def add_failure_attempt(self): """Log user failure attempts into the system. diff --git a/frappe/automation/doctype/milestone_tracker/test_milestone_tracker.py b/frappe/automation/doctype/milestone_tracker/test_milestone_tracker.py index 3242145bc4..4316edd1ca 100644 --- a/frappe/automation/doctype/milestone_tracker/test_milestone_tracker.py +++ b/frappe/automation/doctype/milestone_tracker/test_milestone_tracker.py @@ -9,7 +9,7 @@ class TestMilestoneTracker(FrappeTestCase): def test_milestone(self): frappe.db.delete("Milestone Tracker") - frappe.cache().delete_key("milestone_tracker_map") + frappe.cache.delete_key("milestone_tracker_map") milestone_tracker = frappe.get_doc( dict(doctype="Milestone Tracker", document_type="ToDo", track_field="status") diff --git a/frappe/boot.py b/frappe/boot.py index 37d89365c4..fb1bd3d7a2 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -21,7 +21,6 @@ from frappe.social.doctype.energy_point_log.energy_point_log import get_energy_p from frappe.social.doctype.energy_point_settings.energy_point_settings import ( is_energy_point_enabled, ) -from frappe.translate import get_lang_dict, get_messages_for_boot, get_translated_doctypes from frappe.utils import add_user_info, cstr, get_system_timezone from frappe.utils.change_log import get_versions from frappe.website.doctype.web_page_view.web_page_view import is_tracking_enabled @@ -29,6 +28,8 @@ from frappe.website.doctype.web_page_view.web_page_view import is_tracking_enabl def get_bootinfo(): """build and return boot info""" + from frappe.translate import get_lang_dict, get_translated_doctypes + frappe.set_user_lang(frappe.session.user) bootinfo = frappe._dict() hooks = frappe.get_hooks() @@ -149,10 +150,8 @@ def get_allowed_report_names(cache=False) -> set[str]: def get_user_pages_or_reports(parent, cache=False): - _cache = frappe.cache() - if cache: - has_role = _cache.get_value("has_role:" + parent, user=frappe.session.user) + has_role = frappe.cache.get_value("has_role:" + parent, user=frappe.session.user) if has_role: return has_role @@ -254,11 +253,13 @@ def get_user_pages_or_reports(parent, cache=False): has_role.pop(r, None) # Expire every six hours - _cache.set_value("has_role:" + parent, has_role, frappe.session.user, 21600) + frappe.cache.set_value("has_role:" + parent, has_role, frappe.session.user, 21600) return has_role def load_translations(bootinfo): + from frappe.translate import get_messages_for_boot + bootinfo["lang"] = frappe.lang bootinfo["__messages"] = get_messages_for_boot() diff --git a/frappe/build.py b/frappe/build.py index b74afa5d06..5a9855ef16 100644 --- a/frappe/build.py +++ b/frappe/build.py @@ -9,9 +9,6 @@ from tempfile import mkdtemp, mktemp from urllib.parse import urlparse import click -import psutil -from requests import head -from requests.exceptions import HTTPError from semantic_version import Version import frappe @@ -27,7 +24,7 @@ class AssetsNotDownloadedError(Exception): pass -class AssetsDontExistError(HTTPError): +class AssetsDontExistError(Exception): pass @@ -78,6 +75,8 @@ def build_missing_files(): def get_assets_link(frappe_head) -> str: + import requests + tag = getoutput( r"cd ../apps/frappe && git show-ref --tags -d | grep %s | sed -e 's,.*" r" refs/tags/,,' -e 's/\^{}//'" % frappe_head @@ -89,7 +88,7 @@ def get_assets_link(frappe_head) -> str: else: url = f"http://assets.frappeframework.com/{frappe_head}.tar.gz" - if not head(url): + if not requests.head(url): reference = f"Release {tag}" if tag else f"Commit {frappe_head}" raise AssetsDontExistError(f"Assets for {reference} don't exist") @@ -227,11 +226,10 @@ def bundle( mode, apps=None, hard_link=False, - make_copy=False, - restore=False, verbose=False, skip_frappe=False, files=None, + save_metafiles=False, ): """concat / minify js files""" setup() @@ -251,6 +249,9 @@ def bundle( command += " --run-build-command" + if save_metafiles: + command += " --save-metafiles" + check_node_executable() frappe_app_path = frappe.get_app_path("frappe", "..") frappe.commands.popen(command, cwd=frappe_app_path, env=get_node_env(), raise_err=True) @@ -277,8 +278,8 @@ def watch(apps=None): def check_node_executable(): node_version = Version(subprocess.getoutput("node -v")[1:]) warn = "⚠️ " - if node_version.major < 14: - click.echo(f"{warn} Please update your node version to 14") + if node_version.major < 18: + click.echo(f"{warn} Please update your node version to 18") if not shutil.which("yarn"): click.echo(f"{warn} Please install yarn using below command and try again.\nnpm install -g yarn") click.echo() @@ -290,6 +291,8 @@ def get_node_env(): def get_safe_max_old_space_size(): + import psutil + safe_max_old_space_size = 0 try: total_memory = psutil.virtual_memory().total / (1024 * 1024) diff --git a/frappe/cache_manager.py b/frappe/cache_manager.py index 9c1754148a..f47478d871 100644 --- a/frappe/cache_manager.py +++ b/frappe/cache_manager.py @@ -1,10 +1,7 @@ # Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE -import json - import frappe -from frappe.desk.notifications import clear_notifications, delete_notification_count_for common_default_keys = ["__default", "__global"] @@ -79,7 +76,7 @@ doctype_cache_keys = ( def clear_user_cache(user=None): - cache = frappe.cache() + from frappe.desk.notifications import clear_notifications # this will automatically reload the global cache # so it is important to clear this first @@ -87,20 +84,19 @@ def clear_user_cache(user=None): if user: for name in user_cache_keys: - cache.hdel(name, user) - cache.delete_keys("user:" + user) + frappe.cache.hdel(name, user) + frappe.cache.delete_keys("user:" + user) clear_defaults_cache(user) else: for name in user_cache_keys: - cache.delete_key(name) + frappe.cache.delete_key(name) clear_defaults_cache() clear_global_cache() def clear_domain_cache(user=None): - cache = frappe.cache() domain_cache_keys = ("domain_restricted_doctypes", "domain_restricted_pages") - cache.delete_value(domain_cache_keys) + frappe.cache.delete_value(domain_cache_keys) def clear_global_cache(): @@ -108,17 +104,17 @@ def clear_global_cache(): clear_doctype_cache() clear_website_cache() - frappe.cache().delete_value(global_cache_keys) - frappe.cache().delete_value(bench_cache_keys) + frappe.cache.delete_value(global_cache_keys) + frappe.cache.delete_value(bench_cache_keys) frappe.setup_module_map() def clear_defaults_cache(user=None): if user: for p in [user] + common_default_keys: - frappe.cache().hdel("defaults", p) + frappe.cache.hdel("defaults", p) elif frappe.flags.in_install != "frappe": - frappe.cache().delete_key("defaults") + frappe.cache.delete_key("defaults") def clear_doctype_cache(doctype=None): @@ -131,15 +127,15 @@ def clear_doctype_cache(doctype=None): def _clear_doctype_cache_form_redis(doctype: str | None = None): - cache = frappe.cache() + from frappe.desk.notifications import delete_notification_count_for for key in ("is_table", "doctype_modules"): - cache.delete_value(key) + frappe.cache.delete_value(key) def clear_single(dt): frappe.clear_document_cache(dt) for name in doctype_cache_keys: - cache.hdel(name, dt) + frappe.cache.hdel(name, dt) if doctype: clear_single(doctype) @@ -163,8 +159,8 @@ def _clear_doctype_cache_form_redis(doctype: str | None = None): else: # clear all for name in doctype_cache_keys: - cache.delete_value(name) - cache.delete_keys("document_cache::") + frappe.cache.delete_value(name) + frappe.cache.delete_keys("document_cache::") def clear_controller_cache(doctype=None): @@ -177,7 +173,7 @@ def clear_controller_cache(doctype=None): def get_doctype_map(doctype, name, filters=None, order_by=None): - return frappe.cache().hget( + return frappe.cache.hget( get_doctype_map_key(doctype), name, lambda: frappe.get_all(doctype, filters=filters, order_by=order_by, ignore_ddl=True), @@ -185,7 +181,7 @@ def get_doctype_map(doctype, name, filters=None, order_by=None): def clear_doctype_map(doctype, name): - frappe.cache().hdel(frappe.scrub(doctype) + "_map", name) + frappe.cache.hdel(frappe.scrub(doctype) + "_map", name) def build_table_count_cache(): @@ -198,7 +194,6 @@ def build_table_count_cache(): ): return - _cache = frappe.cache() table_name = frappe.qb.Field("table_name").as_("name") table_rows = frappe.qb.Field("table_rows").as_("count") information_schema = frappe.qb.Schema("information_schema") @@ -207,7 +202,7 @@ def build_table_count_cache(): as_dict=True ) counts = {d.get("name").replace("tab", "", 1): d.get("count", None) for d in data} - _cache.set_value("information_schema:counts", counts) + frappe.cache.set_value("information_schema:counts", counts) return counts @@ -221,11 +216,10 @@ def build_domain_restriced_doctype_cache(*args, **kwargs): or frappe.flags.in_setup_wizard ): return - _cache = frappe.cache() active_domains = frappe.get_active_domains() doctypes = frappe.get_all("DocType", filters={"restrict_to_domain": ("IN", active_domains)}) doctypes = [doc.name for doc in doctypes] - _cache.set_value("domain_restricted_doctypes", doctypes) + frappe.cache.set_value("domain_restricted_doctypes", doctypes) return doctypes @@ -239,10 +233,9 @@ def build_domain_restriced_page_cache(*args, **kwargs): or frappe.flags.in_setup_wizard ): return - _cache = frappe.cache() active_domains = frappe.get_active_domains() pages = frappe.get_all("Page", filters={"restrict_to_domain": ("IN", active_domains)}) pages = [page.name for page in pages] - _cache.set_value("domain_restricted_pages", pages) + frappe.cache.set_value("domain_restricted_pages", pages) return pages diff --git a/frappe/commands/redis_utils.py b/frappe/commands/redis_utils.py index 1c3292b7fa..07ad8715c4 100644 --- a/frappe/commands/redis_utils.py +++ b/frappe/commands/redis_utils.py @@ -3,7 +3,6 @@ import os import click import frappe -from frappe.installer import update_site_config from frappe.utils.redis_queue import RedisQueue @@ -23,6 +22,8 @@ def create_rq_users(set_admin_password=False, use_rq_auth=False): acl config file will be used by redis server while starting the server and app config is used by app while connecting to redis server. """ + from frappe.installer import update_site_config + acl_file_path = os.path.abspath("../config/redis_queue.acl") with frappe.init_site(): diff --git a/frappe/commands/site.py b/frappe/commands/site.py index 25c8c3159d..d606bb78cf 100644 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -9,7 +9,6 @@ import click # imports - module imports import frappe from frappe.commands import get_site, pass_context -from frappe.core.doctype.log_settings.log_settings import LOG_DOCTYPES from frappe.exceptions import SiteNotSpecifiedError @@ -1199,11 +1198,12 @@ def build_search_index(context): @click.command("clear-log-table") -@click.option("--doctype", required=True, type=click.Choice(LOG_DOCTYPES), help="Log DocType") +@click.option("--doctype", required=True, type=str, help="Log DocType") @click.option("--days", type=int, help="Keep records for days") @click.option("--no-backup", is_flag=True, default=False, help="Do not backup the table") @pass_context def clear_log_table(context, doctype, days, no_backup): + """If any logtype table grows too large then clearing it with DELETE query is not feasible in reasonable time. This command copies recent data to new table and replaces current table with new smaller table. @@ -1211,6 +1211,7 @@ def clear_log_table(context, doctype, days, no_backup): ref: https://mariadb.com/kb/en/big-deletes/#deleting-more-than-half-a-table """ + from frappe.core.doctype.log_settings.log_settings import LOG_DOCTYPES from frappe.core.doctype.log_settings.log_settings import clear_log_table as clear_logs from frappe.utils.backups import scheduled_backup diff --git a/frappe/commands/translate.py b/frappe/commands/translate.py index 69970d8d97..5042843405 100644 --- a/frappe/commands/translate.py +++ b/frappe/commands/translate.py @@ -102,10 +102,28 @@ def import_translations(context, lang, path): frappe.destroy() +@click.command("migrate-translations") +@click.argument("source-app") +@click.argument("target-app") +@pass_context +def migrate_translations(context, source_app, target_app): + "Migrate target-app-specific translations from source-app to target-app" + import frappe.translate + + site = get_site(context) + try: + frappe.init(site=site) + frappe.connect() + frappe.translate.migrate_translations(source_app, target_app) + finally: + frappe.destroy() + + commands = [ build_message_files, get_untranslated, import_translations, new_language, update_translations, + migrate_translations, ] diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index e44009a886..e77376b693 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -13,10 +13,6 @@ from frappe.exceptions import SiteNotSpecifiedError from frappe.utils import cint, update_progress_bar find_executable = which # backwards compatibility -DATA_IMPORT_DEPRECATION = ( - "[DEPRECATED] The `import-csv` command used 'Data Import Legacy' which has been deprecated.\n" - "Use `data-import` command instead to import data via 'Data Import'." -) EXTRA_ARGS_CTX = {"ignore_unknown_options": True, "allow_extra_args": True} @@ -30,32 +26,25 @@ EXTRA_ARGS_CTX = {"ignore_unknown_options": True, "allow_extra_args": True} help="Copy the files instead of symlinking", envvar="FRAPPE_HARD_LINK_ASSETS", ) -@click.option( - "--make-copy", - is_flag=True, - default=False, - help="[DEPRECATED] Copy the files instead of symlinking", -) -@click.option( - "--restore", - is_flag=True, - default=False, - help="[DEPRECATED] Copy the files instead of symlinking with force", -) @click.option("--production", is_flag=True, default=False, help="Build assets in production mode") @click.option("--verbose", is_flag=True, default=False, help="Verbose") @click.option( "--force", is_flag=True, default=False, help="Force build assets instead of downloading available" ) +@click.option( + "--save-metafiles", + is_flag=True, + default=False, + help="Saves esbuild metafiles for built assets. Useful for analyzing bundle size. More info: https://esbuild.github.io/api/#metafile", +) def build( app=None, apps=None, hard_link=False, - make_copy=False, - restore=False, production=False, verbose=False, force=False, + save_metafiles=False, ): "Compile JS and CSS source files" from frappe.build import bundle, download_frappe_assets @@ -80,14 +69,14 @@ def build( if production: mode = "production" - if make_copy or restore: - hard_link = make_copy or restore - click.secho( - "bench build: --make-copy and --restore options are deprecated in favour of --hard-link", - fg="yellow", - ) - - bundle(mode, apps=apps, hard_link=hard_link, verbose=verbose, skip_frappe=skip_frappe) + bundle( + mode, + apps=apps, + hard_link=hard_link, + verbose=verbose, + skip_frappe=skip_frappe, + save_metafiles=save_metafiles, + ) @click.command("watch") @@ -409,37 +398,16 @@ def import_doc(context, path, force=False): raise SiteNotSpecifiedError -@click.command("import-csv", help=DATA_IMPORT_DEPRECATION) -@click.argument("path") -@click.option( - "--only-insert", default=False, is_flag=True, help="Do not overwrite existing records" -) -@click.option( - "--submit-after-import", default=False, is_flag=True, help="Submit document after importing it" -) -@click.option( - "--ignore-encoding-errors", - default=False, - is_flag=True, - help="Ignore encoding errors while coverting to unicode", -) -@click.option("--no-email", default=True, is_flag=True, help="Send email if applicable") -@pass_context -def import_csv( - context, - path, - only_insert=False, - submit_after_import=False, - ignore_encoding_errors=False, - no_email=True, -): - click.secho(DATA_IMPORT_DEPRECATION, fg="yellow") - sys.exit(1) - - @click.command("data-import") @click.option( - "--file", "file_path", type=click.Path(), required=True, help="Path to import file (.csv, .xlsx)" + "--file", + "file_path", + type=click.Path(exists=True, dir_okay=False, resolve_path=True), + required=True, + help=( + "Path to import file (.csv, .xlsx)." + "Consider that relative paths will resolve from 'sites' directory" + ), ) @click.option("--doctype", type=str, required=True) @click.option( @@ -765,7 +733,6 @@ def transform_database(context, table, engine, row_format, failfast): help="Path to .txt file for list of doctypes. Example erpnext/tests/server/agriculture.txt", ) @click.option("--test", multiple=True, help="Specific test") -@click.option("--ui-tests", is_flag=True, default=False, help="Run UI Tests") @click.option("--module", help="Run tests in a module") @click.option("--profile", is_flag=True, default=False) @click.option("--coverage", is_flag=True, default=False) @@ -788,7 +755,6 @@ def run_tests( profile=False, coverage=False, junit_xml_output=False, - ui_tests=False, doctype_list_path=None, skip_test_records=False, skip_before_tests=False, @@ -827,7 +793,6 @@ def run_tests( force=context.force, profile=profile, junit_xml_output=junit_xml_output, - ui_tests=ui_tests, doctype_list_path=doctype_list_path, failfast=failfast, case=case, @@ -1063,20 +1028,11 @@ def create_patch(): "-g", "--global", "global_", is_flag=True, default=False, help="Set value in bench config" ) @click.option("-p", "--parse", is_flag=True, default=False, help="Evaluate as Python Object") -@click.option("--as-dict", is_flag=True, default=False, help="Legacy: Evaluate as Python Object") @pass_context -def set_config(context, key, value, global_=False, parse=False, as_dict=False): +def set_config(context, key, value, global_=False, parse=False): "Insert/Update a value in site_config.json" from frappe.installer import update_site_config - if as_dict: - from frappe.utils.commands import warn - - warn( - "--as-dict will be deprecated in v14. Use --parse instead", category=PendingDeprecationWarning - ) - parse = as_dict - if parse: import ast @@ -1200,7 +1156,6 @@ commands = [ export_fixtures, export_json, get_version, - import_csv, data_import, import_doc, make_app, diff --git a/frappe/core/doctype/amended_document_naming_settings/__init__.py b/frappe/core/doctype/amended_document_naming_settings/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/core/doctype/amended_document_naming_settings/amended_document_naming_settings.json b/frappe/core/doctype/amended_document_naming_settings/amended_document_naming_settings.json new file mode 100644 index 0000000000..2892cc6091 --- /dev/null +++ b/frappe/core/doctype/amended_document_naming_settings/amended_document_naming_settings.json @@ -0,0 +1,44 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2023-06-16 17:57:36.604672", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "document_type", + "action" + ], + "fields": [ + { + "default": "Amend Counter", + "fieldname": "action", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Action", + "options": "Amend Counter\nDefault Naming", + "reqd": 1 + }, + { + "fieldname": "document_type", + "fieldtype": "Link", + "in_list_view": 1, + "label": "DocType", + "options": "DocType", + "reqd": 1, + "unique": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2023-06-16 18:26:16.247475", + "modified_by": "Administrator", + "module": "Core", + "name": "Amended Document Naming Settings", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/frappe/core/doctype/amended_document_naming_settings/amended_document_naming_settings.py b/frappe/core/doctype/amended_document_naming_settings/amended_document_naming_settings.py new file mode 100644 index 0000000000..91b31350b0 --- /dev/null +++ b/frappe/core/doctype/amended_document_naming_settings/amended_document_naming_settings.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe Technologies and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class AmendedDocumentNamingSettings(Document): + pass diff --git a/frappe/core/doctype/data_import/importer.py b/frappe/core/doctype/data_import/importer.py index 20a8e7db9b..6d6e34d97d 100644 --- a/frappe/core/doctype/data_import/importer.py +++ b/frappe/core/doctype/data_import/importer.py @@ -62,7 +62,7 @@ class Importer: def before_import(self): # set user lang for translations - frappe.cache().hdel("lang", frappe.session.user) + frappe.cache.hdel("lang", frappe.session.user) frappe.set_user_lang(frappe.session.user) # set flags @@ -579,6 +579,10 @@ class ImportFile: file_content = None + if self.console: + file_content = frappe.read_file(file_path, True) + return file_content, extn + file_name = frappe.db.get_value("File", {"file_url": file_path}) if file_name: file = frappe.get_doc("File", file_name) @@ -690,7 +694,7 @@ class Row: df = col.df if df.fieldtype == "Select": select_options = get_select_options(df) - if select_options and value not in select_options: + if select_options and cstr(value) not in select_options: options_string = ", ".join(frappe.bold(d) for d in select_options) msg = _("Value must be one of {0}").format(options_string) self.warnings.append( @@ -1207,7 +1211,7 @@ def get_df_for_column_header(doctype, header): def build_fields_dict_for_doctype(): return build_fields_dict_for_column_matching(doctype) - df_by_labels_and_fieldname = frappe.cache().hget( + df_by_labels_and_fieldname = frappe.cache.hget( "data_import_column_header_map", doctype, generator=build_fields_dict_for_doctype ) return df_by_labels_and_fieldname.get(header) diff --git a/frappe/core/doctype/docfield/docfield.json b/frappe/core/doctype/docfield/docfield.json index 90b1c6cb77..f297e0dbe6 100644 --- a/frappe/core/doctype/docfield/docfield.json +++ b/frappe/core/doctype/docfield/docfield.json @@ -21,6 +21,7 @@ "search_index", "column_break_18", "options", + "sort_options", "show_dashboard", "defaults_section", "default", @@ -102,7 +103,8 @@ "oldfieldtype": "Select", "options": "Autocomplete\nAttach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nJSON\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nPhone\nRead Only\nRating\nSection Break\nSelect\nSignature\nSmall Text\nTab Break\nTable\nTable MultiSelect\nText\nText Editor\nTime", "reqd": 1, - "search_index": 1 + "search_index": 1, + "sort_options": 1 }, { "bold": 1, @@ -550,13 +552,20 @@ "fieldtype": "Data", "label": "Documentation URL", "options": "URL" + }, + { + "default": "0", + "depends_on": "eval: doc.fieldtype === 'Select'", + "fieldname": "sort_options", + "fieldtype": "Check", + "label": "Sort Options" } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2023-02-20 12:07:29.552523", + "modified": "2023-06-08 19:05:10.778371", "modified_by": "Administrator", "module": "Core", "name": "DocField", diff --git a/frappe/core/doctype/docshare/docshare.json b/frappe/core/doctype/docshare/docshare.json index ca10b05dac..e3581f516e 100644 --- a/frappe/core/doctype/docshare/docshare.json +++ b/frappe/core/doctype/docshare/docshare.json @@ -67,7 +67,8 @@ "default": "0", "fieldname": "everyone", "fieldtype": "Check", - "label": "Everyone" + "label": "Everyone", + "search_index": 1 }, { "default": "1", @@ -85,10 +86,11 @@ ], "in_create": 1, "links": [], - "modified": "2021-04-04 11:38:50.813312", + "modified": "2023-06-15 18:02:51.877533", "modified_by": "Administrator", "module": "Core", "name": "DocShare", + "naming_rule": "Random", "owner": "Administrator", "permissions": [ { @@ -106,5 +108,6 @@ "read_only": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/frappe/core/doctype/doctype/boilerplate/__init__.py b/frappe/core/doctype/doctype/boilerplate/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/core/doctype/doctype/doctype.json b/frappe/core/doctype/doctype/doctype.json index 842898d064..5209a408eb 100644 --- a/frappe/core/doctype/doctype/doctype.json +++ b/frappe/core/doctype/doctype/doctype.json @@ -755,4 +755,4 @@ "states": [], "track_changes": 1, "translated_doctype": 1 -} \ No newline at end of file +} diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index 12545adb4e..6a1887f14a 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -337,7 +337,7 @@ class DocType(Document): "DocField", "parent", dict(fieldtype=["in", frappe.model.table_fields], options=self.name) ) for p in parent_list: - frappe.db.set_value("DocType", p.parent, {}, for_update=False) + frappe.db.set_value("DocType", p.parent, {}) def scrub_field_names(self): """Sluggify fieldnames if not set from Label.""" @@ -1710,7 +1710,7 @@ def check_fieldname_conflicts(docfield): def clear_linked_doctype_cache(): - frappe.cache().delete_value("linked_doctypes_without_ignore_user_permissions_enabled") + frappe.cache.delete_value("linked_doctypes_without_ignore_user_permissions_enabled") def check_email_append_to(doc): diff --git a/frappe/core/doctype/document_naming_settings/document_naming_settings.js b/frappe/core/doctype/document_naming_settings/document_naming_settings.js index 2a9ec4aae5..f19e197249 100644 --- a/frappe/core/doctype/document_naming_settings/document_naming_settings.js +++ b/frappe/core/doctype/document_naming_settings/document_naming_settings.js @@ -2,6 +2,16 @@ // For license information, please see license.txt frappe.ui.form.on("Document Naming Settings", { + setup: function (frm) { + frm.set_query("document_type", "amend_naming_override", () => { + return { + filters: { + is_submittable: 1, + }, + }; + }); + }, + refresh: function (frm) { frm.trigger("setup_transaction_autocomplete"); frm.disable_save(); diff --git a/frappe/core/doctype/document_naming_settings/document_naming_settings.json b/frappe/core/doctype/document_naming_settings/document_naming_settings.json index 9a12f3f77e..5a1991c14b 100644 --- a/frappe/core/doctype/document_naming_settings/document_naming_settings.json +++ b/frappe/core/doctype/document_naming_settings/document_naming_settings.json @@ -18,7 +18,11 @@ "update_series", "prefix", "current_value", - "update_series_start" + "update_series_start", + "amended_documents_section", + "default_amend_naming", + "amend_naming_override", + "update_amendment_naming" ], "fields": [ { @@ -105,13 +109,41 @@ "fieldtype": "Text", "label": "Preview of generated names", "read_only": 1 + }, + { + "collapsible": 1, + "description": "Configure how amended documents will be named.
\n\nDefault behaviour is to follow an amend counter which adds a number to the end of the original name indicating the amended version.
\n\nDefault Naming will make the amended document to behave same as new documents.", + "fieldname": "amended_documents_section", + "fieldtype": "Section Break", + "label": "Amended Documents" + }, + { + "default": "Amend Counter", + "fieldname": "default_amend_naming", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Default Amendment Naming", + "options": "Amend Counter\nDefault Naming", + "reqd": 1 + }, + { + "fieldname": "amend_naming_override", + "fieldtype": "Table", + "label": "Amendment Naming Override", + "options": "Amended Document Naming Settings" + }, + { + "fieldname": "update_amendment_naming", + "fieldtype": "Button", + "label": "Update Amendment Naming", + "options": "update_amendment_rule" } ], "hide_toolbar": 1, "icon": "fa fa-sort-by-order", "issingle": 1, "links": [], - "modified": "2023-02-20 13:11:56.662100", + "modified": "2023-06-20 17:47:52.204139", "modified_by": "Administrator", "module": "Core", "name": "Document Naming Settings", diff --git a/frappe/core/doctype/document_naming_settings/document_naming_settings.py b/frappe/core/doctype/document_naming_settings/document_naming_settings.py index f8647bd74a..625b7cdd50 100644 --- a/frappe/core/doctype/document_naming_settings/document_naming_settings.py +++ b/frappe/core/doctype/document_naming_settings/document_naming_settings.py @@ -169,6 +169,23 @@ class DocumentNamingSettings(Document): self.current_value = NamingSeries(self.prefix).get_current_value() return self.current_value + @frappe.whitelist() + def update_amendment_rule(self): + self.db_set("default_amend_naming", self.default_amend_naming) + + existing_overrides = frappe.db.get_all( + "Amended Document Naming Settings", + filters={"name": ["not in", [d.name for d in self.amend_naming_override]]}, + pluck="name", + ) + for override in existing_overrides: + frappe.delete_doc("Amended Document Naming Settings", override) + + for row in self.amend_naming_override: + row.save() + + frappe.msgprint(_("Amendment naming rules updated."), indicator="green", alert=True) + @frappe.whitelist() def update_series_start(self): frappe.only_for("System Manager") diff --git a/frappe/core/doctype/document_naming_settings/test_document_naming_settings.py b/frappe/core/doctype/document_naming_settings/test_document_naming_settings.py index bcd3197112..d1a6fbe90d 100644 --- a/frappe/core/doctype/document_naming_settings/test_document_naming_settings.py +++ b/frappe/core/doctype/document_naming_settings/test_document_naming_settings.py @@ -26,6 +26,7 @@ class TestNamingSeries(FrappeTestCase): } ], autoname="naming_series:", + is_submittable=1, ) .insert() .name @@ -82,3 +83,36 @@ class TestNamingSeries(FrappeTestCase): self.dns.update_series_start() self.assertEqual(self.dns.get_current(), new_count, f"Incorrect update for {series}") + + def test_amended_naming(self): + self.dns.amend_naming_override = [] + self.dns.default_amend_naming = "Amend Counter" + self.dns.update_amendment_rule() + + submittable_doc = frappe.get_doc( + dict(doctype=self.ns_doctype, some_fieldname="test doc with submit") + ).submit() + submittable_doc.cancel() + + amended_doc = frappe.get_doc( + dict( + doctype=self.ns_doctype, + some_fieldname="test doc with submit", + amended_from=submittable_doc.name, + ) + ).insert() + + self.assertIn(submittable_doc.name, amended_doc.name) + amended_doc.delete() + + self.dns.default_amend_naming = "Default Naming" + self.dns.update_amendment_rule() + + new_amended_doc = frappe.get_doc( + dict( + doctype=self.ns_doctype, + some_fieldname="test doc with submit", + amended_from=submittable_doc.name, + ) + ).insert() + self.assertNotIn(submittable_doc.name, new_amended_doc.name) diff --git a/frappe/core/doctype/domain_settings/domain_settings.py b/frappe/core/doctype/domain_settings/domain_settings.py index 85b26f53dd..d963a14830 100644 --- a/frappe/core/doctype/domain_settings/domain_settings.py +++ b/frappe/core/doctype/domain_settings/domain_settings.py @@ -73,7 +73,7 @@ def get_active_domains(): active_domains.append("") return active_domains - return frappe.cache().get_value("active_domains", _get_active_domains) + return frappe.cache.get_value("active_domains", _get_active_domains) def get_active_modules(): @@ -87,4 +87,4 @@ def get_active_modules(): active_modules.append(m.name) return active_modules - return frappe.cache().get_value("active_modules", _get_active_modules) + return frappe.cache.get_value("active_modules", _get_active_modules) diff --git a/frappe/core/doctype/error_log/test_error_log.py b/frappe/core/doctype/error_log/test_error_log.py index 59670de8d2..3f19a6dd0c 100644 --- a/frappe/core/doctype/error_log/test_error_log.py +++ b/frappe/core/doctype/error_log/test_error_log.py @@ -1,7 +1,10 @@ # Copyright (c) 2015, Frappe Technologies and Contributors # License: MIT. See LICENSE +from ldap3.core.exceptions import LDAPException, LDAPInappropriateAuthenticationResult + import frappe from frappe.tests.utils import FrappeTestCase +from frappe.utils.error import _is_ldap_exception # test_records = frappe.get_test_records('Error Log') @@ -12,3 +15,9 @@ class TestErrorLog(FrappeTestCase): doc = frappe.new_doc("Error Log") error = doc.log_error("This is an error") self.assertEqual(error.doctype, "Error Log") + + def test_ldap_exceptions(self): + exc = [LDAPException, LDAPInappropriateAuthenticationResult] + + for e in exc: + self.assertTrue(_is_ldap_exception(e())) diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index c4cefc7271..7770226e79 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -236,12 +236,19 @@ class File(Document): ): return - frappe.db.set_value( - self.attached_to_doctype, - self.attached_to_name, - self.attached_to_field, - self.file_url, - ) + if frappe.get_meta(self.attached_to_doctype).issingle: + frappe.db.set_single_value( + self.attached_to_doctype, + self.attached_to_field, + self.file_url, + ) + else: + frappe.db.set_value( + self.attached_to_doctype, + self.attached_to_name, + self.attached_to_field, + self.file_url, + ) def fetch_attached_to_field(self, old_file_url): if self.attached_to_field: diff --git a/frappe/core/doctype/report/report.py b/frappe/core/doctype/report/report.py index 9b2a2ccc18..8cdbc24074 100644 --- a/frappe/core/doctype/report/report.py +++ b/frappe/core/doctype/report/report.py @@ -137,7 +137,7 @@ class Report(Document): if execution_time > threshold and not self.prepared_report: self.db_set("prepared_report", 1) - frappe.cache().hset("report_execution_time", self.name, execution_time) + frappe.cache.hset("report_execution_time", self.name, execution_time) return res diff --git a/frappe/core/doctype/role/role.py b/frappe/core/doctype/role/role.py index 31b82501cb..8e5ec269ea 100644 --- a/frappe/core/doctype/role/role.py +++ b/frappe/core/doctype/role/role.py @@ -24,7 +24,7 @@ class Role(Document): frappe.throw(frappe._("Standard roles cannot be renamed")) def after_insert(self): - frappe.cache().hdel("roles", "Administrator") + frappe.cache.hdel("roles", "Administrator") def validate(self): if self.disabled: diff --git a/frappe/core/doctype/rq_job/test_rq_job.py b/frappe/core/doctype/rq_job/test_rq_job.py index 09a90f7445..c39717cfd8 100644 --- a/frappe/core/doctype/rq_job/test_rq_job.py +++ b/frappe/core/doctype/rq_job/test_rq_job.py @@ -124,6 +124,20 @@ class TestRQJob(FrappeTestCase): frappe.db.commit() self.assertIsNone(get_job_status(job_id)) + @timeout(20) + def test_memory_usage(self): + job = frappe.enqueue("frappe.utils.data._get_rss_memory_usage") + self.check_status(job, "finished") + + rss = job.latest_result().return_value + msg = """Memory usage of simple background job increased. Potential root cause can be a newly added python module import. Check and move them to approriate file/function to avoid loading the module by default.""" + + # If this starts failing analyze memory usage using memray or some equivalent tool to find + # offending imports/function calls. + # Refer this PR: https://github.com/frappe/frappe/pull/21467 + LAST_MEASURED_USAGE = 40 + self.assertLessEqual(rss, LAST_MEASURED_USAGE * 1.05, msg) + def test_func(fail=False, sleep=0): if fail: diff --git a/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py b/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py index bbc92dfbc9..7edad24ac4 100644 --- a/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py +++ b/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py @@ -18,7 +18,7 @@ class TestScheduledJobType(FrappeTestCase): self.assertEqual(all_job.frequency, "All") daily_job = frappe.get_doc( - "Scheduled Job Type", dict(method="frappe.email.queue.set_expiry_for_email_queue") + "Scheduled Job Type", dict(method="frappe.desk.notifications.clear_notifications") ) self.assertEqual(daily_job.frequency, "Daily") @@ -37,7 +37,7 @@ class TestScheduledJobType(FrappeTestCase): def test_daily_job(self): job = frappe.get_doc( - "Scheduled Job Type", dict(method="frappe.email.queue.set_expiry_for_email_queue") + "Scheduled Job Type", dict(method="frappe.desk.notifications.clear_notifications") ) job.db_set("last_execution", "2019-01-01 00:00:00") self.assertTrue(job.is_event_due(get_datetime("2019-01-02 00:00:06"))) diff --git a/frappe/core/doctype/server_script/server_script.py b/frappe/core/doctype/server_script/server_script.py index 07808d619b..758bd46a76 100644 --- a/frappe/core/doctype/server_script/server_script.py +++ b/frappe/core/doctype/server_script/server_script.py @@ -19,7 +19,7 @@ class ServerScript(Document): self.check_if_compilable_in_restricted_context() def on_update(self): - frappe.cache().delete_value("server_script_map") + frappe.cache.delete_value("server_script_map") self.sync_scheduler_events() def on_trash(self): @@ -168,11 +168,11 @@ class ServerScript(Document): out.append([key, score]) return out - items = frappe.cache().get_value("server_script_autocompletion_items") + items = frappe.cache.get_value("server_script_autocompletion_items") if not items: items = get_keys(get_safe_globals()) items = [{"value": d[0], "score": d[1]} for d in items] - frappe.cache().set_value("server_script_autocompletion_items", items) + frappe.cache.set_value("server_script_autocompletion_items", items) return items diff --git a/frappe/core/doctype/server_script/server_script_utils.py b/frappe/core/doctype/server_script/server_script_utils.py index b807b43d10..6ba65e7353 100644 --- a/frappe/core/doctype/server_script/server_script_utils.py +++ b/frappe/core/doctype/server_script/server_script_utils.py @@ -55,7 +55,7 @@ def get_server_script_map(): if frappe.flags.in_patch and not frappe.db.table_exists("Server Script"): return {} - script_map = frappe.cache().get_value("server_script_map") + script_map = frappe.cache.get_value("server_script_map") if script_map is None: script_map = {"permission_query": {}} enabled_server_scripts = frappe.get_all( @@ -73,6 +73,6 @@ def get_server_script_map(): else: script_map.setdefault("_api", {})[script.api_method] = script.name - frappe.cache().set_value("server_script_map", script_map) + frappe.cache.set_value("server_script_map", script_map) return script_map diff --git a/frappe/core/doctype/server_script/test_server_script.py b/frappe/core/doctype/server_script/test_server_script.py index 4371806b32..af1352f02b 100644 --- a/frappe/core/doctype/server_script/test_server_script.py +++ b/frappe/core/doctype/server_script/test_server_script.py @@ -104,10 +104,10 @@ class TestServerScript(FrappeTestCase): def tearDownClass(cls): frappe.db.commit() frappe.db.truncate("Server Script") - frappe.cache().delete_value("server_script_map") + frappe.cache.delete_value("server_script_map") def setUp(self): - frappe.cache().delete_value("server_script_map") + frappe.cache.delete_value("server_script_map") def test_doctype_event(self): todo = frappe.get_doc(dict(doctype="ToDo", description="hello")).insert() diff --git a/frappe/core/doctype/system_settings/system_settings.py b/frappe/core/doctype/system_settings/system_settings.py index c4f35f3cc0..0c842f9c7d 100644 --- a/frappe/core/doctype/system_settings/system_settings.py +++ b/frappe/core/doctype/system_settings/system_settings.py @@ -5,14 +5,13 @@ import frappe from frappe import _ from frappe.model import no_value_fields from frappe.model.document import Document -from frappe.translate import set_default_language -from frappe.twofactor import toggle_two_factor_auth from frappe.utils import cint, today -from frappe.utils.momentjs import get_all_timezones class SystemSettings(Document): def validate(self): + from frappe.twofactor import toggle_two_factor_auth + enable_password_policy = cint(self.enable_password_policy) and True or False minimum_password_score = cint(getattr(self, "minimum_password_score", 0)) or 0 if enable_password_policy and minimum_password_score <= 0: @@ -64,13 +63,15 @@ class SystemSettings(Document): def on_update(self): self.set_defaults() - frappe.cache().delete_value("system_settings") - frappe.cache().delete_value("time_zone") + frappe.cache.delete_value("system_settings") + frappe.cache.delete_value("time_zone") if frappe.flags.update_last_reset_password_date: update_last_reset_password_date() def set_defaults(self): + from frappe.translate import set_default_language + for df in self.meta.get("fields"): if df.fieldtype not in no_value_fields and self.has_value_changed(df.fieldname): frappe.db.set_default(df.fieldname, self.get(df.fieldname)) @@ -92,6 +93,8 @@ def update_last_reset_password_date(): @frappe.whitelist() def load(): + from frappe.utils.momentjs import get_all_timezones + if not "System Manager" in frappe.get_roles(): frappe.throw(_("Not permitted"), frappe.PermissionError) diff --git a/frappe/core/doctype/translation/translation.py b/frappe/core/doctype/translation/translation.py index 6afad00fad..c8226db5b0 100644 --- a/frappe/core/doctype/translation/translation.py +++ b/frappe/core/doctype/translation/translation.py @@ -23,71 +23,7 @@ class Translation(Document): def on_trash(self): clear_user_translation_cache(self.language) - def contribute(self): - pass - - def get_contribution_status(self): - pass - - -@frappe.whitelist() -def create_translations(translation_map, language): - from frappe.frappeclient import FrappeClient - - translation_map = json.loads(translation_map) - translation_map_to_send = frappe._dict({}) - # first create / update local user translations - for source_id, translation_dict in translation_map.items(): - translation_dict = frappe._dict(translation_dict) - existing_doc_name = frappe.get_all( - "Translation", - { - "source_text": translation_dict.source_text, - "context": translation_dict.context or "", - "language": language, - }, - ) - translation_map_to_send[source_id] = translation_dict - if existing_doc_name: - frappe.db.set_value( - "Translation", - existing_doc_name[0].name, - { - "translated_text": translation_dict.translated_text, - "contributed": 1, - "contribution_status": "Pending", - }, - ) - translation_map_to_send[source_id].name = existing_doc_name[0].name - else: - doc = frappe.get_doc( - { - "doctype": "Translation", - "source_text": translation_dict.source_text, - "contributed": 1, - "contribution_status": "Pending", - "translated_text": translation_dict.translated_text, - "context": translation_dict.context, - "language": language, - } - ) - doc.insert() - translation_map_to_send[source_id].name = doc.name - - params = { - "language": language, - "contributor_email": frappe.session.user, - "contributor_name": frappe.utils.get_fullname(frappe.session.user), - "translation_map": json.dumps(translation_map_to_send), - } - - translator = FrappeClient(get_translator_url()) - added_translations = translator.post_api("translator.api.add_translations", params=params) - - for local_docname, remote_docname in added_translations.items(): - frappe.db.set_value("Translation", local_docname, "contribution_docname", remote_docname) - def clear_user_translation_cache(lang): - frappe.cache().hdel(USER_TRANSLATION_KEY, lang) - frappe.cache().hdel(MERGED_TRANSLATION_KEY, lang) + frappe.cache.hdel(USER_TRANSLATION_KEY, lang) + frappe.cache.hdel(MERGED_TRANSLATION_KEY, lang) diff --git a/frappe/core/doctype/user/test_user.py b/frappe/core/doctype/user/test_user.py index d39d2062eb..b4d69d23d5 100644 --- a/frappe/core/doctype/user/test_user.py +++ b/frappe/core/doctype/user/test_user.py @@ -283,7 +283,7 @@ class TestUser(FrappeTestCase): # Clear rate limit tracker to start fresh key = f"rl:{data['cmd']}:{data['user']}" - frappe.cache().delete(key) + frappe.cache.delete(key) c = FrappeClient(url) res1 = c.session.post(url, data=data, verify=c.verify, headers=c.headers) @@ -330,7 +330,7 @@ class TestUser(FrappeTestCase): sign_up(random_user, random_user_name, "/welcome"), (1, "Please check your email for verification"), ) - self.assertEqual(frappe.cache().hget("redirect_after_login", random_user), "/welcome") + self.assertEqual(frappe.cache.hget("redirect_after_login", random_user), "/welcome") # re-register self.assertTupleEqual( diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 81d9715c32..9bcc9ebd3d 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -28,6 +28,7 @@ from frappe.utils import ( now_datetime, today, ) +from frappe.utils.deprecations import deprecated from frappe.utils.password import check_password, get_password_reset_limit from frappe.utils.password import update_password as _update_password from frappe.utils.user import get_system_managers @@ -60,8 +61,8 @@ class User(Document): def after_insert(self): create_notification_settings(self.name) - frappe.cache().delete_key("users_for_mentions") - frappe.cache().delete_key("enabled_users") + frappe.cache.delete_key("users_for_mentions") + frappe.cache.delete_key("enabled_users") def validate(self): # clear new password @@ -75,6 +76,7 @@ class User(Document): self.validate_email_type(self.email) self.validate_email_type(self.name) self.add_system_manager_role() + self.populate_role_profile_roles() self.check_roles_added() self.set_system_user() self.set_full_name() @@ -85,7 +87,6 @@ class User(Document): self.remove_disabled_roles() self.validate_user_email_inbox() ask_pass_update() - self.validate_roles() self.validate_allowed_modules() self.validate_user_image() self.set_time_zone() @@ -98,12 +99,16 @@ class User(Document): ): self.set_social_login_userid("frappe", frappe.generate_hash(length=39)) - def validate_roles(self): + def populate_role_profile_roles(self): if self.role_profile_name: role_profile = frappe.get_doc("Role Profile", self.role_profile_name) self.set("roles", []) self.append_roles(*[role.role for role in role_profile.roles]) + @deprecated + def validate_roles(self): + self.populate_role_profile_roles() + def validate_allowed_modules(self): if self.module_profile: module_profile = frappe.get_doc("Module Profile", self.module_profile) @@ -143,10 +148,10 @@ class User(Document): frappe.defaults.set_default("time_zone", self.time_zone, self.name) if self.has_value_changed("enabled"): - frappe.cache().delete_key("users_for_mentions") - frappe.cache().delete_key("enabled_users") + frappe.cache.delete_key("users_for_mentions") + frappe.cache.delete_key("enabled_users") elif self.has_value_changed("allow_in_mentions") or self.has_value_changed("user_type"): - frappe.cache().delete_key("users_for_mentions") + frappe.cache.delete_key("users_for_mentions") def has_website_permission(self, ptype, user, verbose=False): """Returns true if current user is the session user""" @@ -462,9 +467,9 @@ class User(Document): frappe.delete_doc("Notification Settings", self.name, ignore_permissions=True) if self.get("allow_in_mentions"): - frappe.cache().delete_key("users_for_mentions") + frappe.cache.delete_key("users_for_mentions") - frappe.cache().delete_key("enabled_users") + frappe.cache.delete_key("enabled_users") # delete user permissions frappe.db.delete("User Permission", {"user": self.name}) @@ -760,10 +765,10 @@ def update_password( user_doc, redirect_url = reset_user_data(user) # get redirect url from cache - redirect_to = frappe.cache().hget("redirect_after_login", user) + redirect_to = frappe.cache.hget("redirect_after_login", user) if redirect_to: redirect_url = redirect_to - frappe.cache().hdel("redirect_after_login", user) + frappe.cache.hdel("redirect_after_login", user) frappe.local.login_manager.login_as(user) @@ -921,7 +926,7 @@ def sign_up(email: str, full_name: str, redirect_to: str) -> tuple[int, str]: user.add_roles(default_role) if redirect_to: - frappe.cache().hset("redirect_after_login", user.name, redirect_to) + frappe.cache.hset("redirect_after_login", user.name, redirect_to) if user.flags.email_sent: return 1, _("Please check your email for verification") @@ -1234,4 +1239,4 @@ def get_enabled_users(): enabled_users = frappe.get_all("User", filters={"enabled": "1"}, pluck="name") return enabled_users - return frappe.cache().get_value("enabled_users", _get_enabled_users) + return frappe.cache.get_value("enabled_users", _get_enabled_users) diff --git a/frappe/core/doctype/user_group/user_group.py b/frappe/core/doctype/user_group/user_group.py index 812f230f7a..7acdec3aaa 100644 --- a/frappe/core/doctype/user_group/user_group.py +++ b/frappe/core/doctype/user_group/user_group.py @@ -9,7 +9,7 @@ from frappe.model.document import Document class UserGroup(Document): def after_insert(self): - frappe.cache().delete_key("user_groups") + frappe.cache.delete_key("user_groups") def on_trash(self): - frappe.cache().delete_key("user_groups") + frappe.cache.delete_key("user_groups") diff --git a/frappe/core/doctype/user_permission/test_user_permission.py b/frappe/core/doctype/user_permission/test_user_permission.py index 8742d2e040..a38ec4d379 100644 --- a/frappe/core/doctype/user_permission/test_user_permission.py +++ b/frappe/core/doctype/user_permission/test_user_permission.py @@ -178,7 +178,7 @@ class TestUserPermission(FrappeTestCase): frappe.db.set_value( "User Permission", {"allow": "Person", "for_value": parent_record.name}, "hide_descendants", 1 ) - frappe.cache().delete_value("user_permissions") + frappe.cache.delete_value("user_permissions") # check if adding perm on a group record with hide_descendants enabled, # hides child records diff --git a/frappe/core/doctype/user_permission/user_permission.py b/frappe/core/doctype/user_permission/user_permission.py index 63c1f40512..57214b82e2 100644 --- a/frappe/core/doctype/user_permission/user_permission.py +++ b/frappe/core/doctype/user_permission/user_permission.py @@ -17,11 +17,11 @@ class UserPermission(Document): self.validate_default_permission() def on_update(self): - frappe.cache().hdel("user_permissions", self.user) + frappe.cache.hdel("user_permissions", self.user) frappe.publish_realtime("update_user_permissions", user=self.user, after_commit=True) def on_trash(self): - frappe.cache().hdel("user_permissions", self.user) + frappe.cache.hdel("user_permissions", self.user) frappe.publish_realtime("update_user_permissions", user=self.user, after_commit=True) def validate_user_permission(self): @@ -74,7 +74,7 @@ def get_user_permissions(user=None): if not user or user in ("Administrator", "Guest"): return {} - cached_user_permissions = frappe.cache().hget("user_permissions", user) + cached_user_permissions = frappe.cache.hget("user_permissions", user) if cached_user_permissions is not None: return cached_user_permissions @@ -110,7 +110,7 @@ def get_user_permissions(user=None): add_doc_to_perm(perm, doc, False) out = frappe._dict(out) - frappe.cache().hset("user_permissions", user, out) + frappe.cache.hset("user_permissions", user, out) except frappe.db.SQLError as e: if frappe.db.is_table_missing(e): # called from patch diff --git a/frappe/core/doctype/user_type/user_type.py b/frappe/core/doctype/user_type/user_type.py index 39d9133412..9660963c19 100644 --- a/frappe/core/doctype/user_type/user_type.py +++ b/frappe/core/doctype/user_type/user_type.py @@ -18,7 +18,7 @@ class UserType(Document): super().clear_cache() if not self.is_standard: - frappe.cache().delete_value("non_standard_user_types") + frappe.cache.delete_value("non_standard_user_types") def on_update(self): if self.is_standard: @@ -290,7 +290,7 @@ def apply_permissions_for_non_standard_user_type(doc, method=None): if not frappe.db.table_exists("User Type") or frappe.flags.in_migrate: return - user_types = frappe.cache().get_value( + user_types = frappe.cache.get_value( "non_standard_user_types", get_non_standard_user_types, ) diff --git a/frappe/core/page/permission_manager/permission_manager.py b/frappe/core/page/permission_manager/permission_manager.py index 5ed3014778..fd879095c0 100644 --- a/frappe/core/page/permission_manager/permission_manager.py +++ b/frappe/core/page/permission_manager/permission_manager.py @@ -123,8 +123,15 @@ def update(doctype, role, permlevel, ptype, value=None): Returns: str: Refresh flag is permission is updated successfully """ + + def clear_cache(): + frappe.clear_cache(doctype=doctype) + frappe.only_for("System Manager") out = update_permission_property(doctype, role, permlevel, ptype, value) + + frappe.db.after_commit.add(clear_cache) + return "refresh" if out else None diff --git a/frappe/custom/doctype/custom_field/custom_field.json b/frappe/custom/doctype/custom_field/custom_field.json index 63be70c644..b685d69192 100644 --- a/frappe/custom/doctype/custom_field/custom_field.json +++ b/frappe/custom/doctype/custom_field/custom_field.json @@ -21,6 +21,7 @@ "hide_seconds", "hide_days", "options", + "sort_options", "fetch_from", "fetch_if_empty", "options_help", @@ -126,7 +127,8 @@ "oldfieldname": "fieldtype", "oldfieldtype": "Select", "options": "Autocomplete\nAttach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nJSON\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nPhone\nRead Only\nRating\nSection Break\nSelect\nSignature\nSmall Text\nTab Break\nTable\nTable MultiSelect\nText\nText Editor\nTime", - "reqd": 1 + "reqd": 1, + "sort_options": 1 }, { "depends_on": "eval:in_list([\"Float\", \"Currency\", \"Percent\"], doc.fieldtype)", @@ -435,13 +437,20 @@ "fieldtype": "Check", "label": "Is System Generated", "read_only": 1 + }, + { + "default": "0", + "depends_on": "eval: doc.fieldtype === 'Select'", + "fieldname": "sort_options", + "fieldtype": "Check", + "label": "Sort Options" } ], "icon": "fa fa-glass", "idx": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2022-06-13 06:39:03.319667", + "modified": "2023-06-08 19:05:51.737234", "modified_by": "Administrator", "module": "Custom", "name": "Custom Field", diff --git a/frappe/custom/doctype/custom_field/custom_field.py b/frappe/custom/doctype/custom_field/custom_field.py index 8953153be6..ed6296b6f2 100644 --- a/frappe/custom/doctype/custom_field/custom_field.py +++ b/frappe/custom/doctype/custom_field/custom_field.py @@ -40,8 +40,9 @@ class CustomField(Document): # remove special characters from fieldname self.fieldname = "".join( - filter(lambda x: x.isdigit() or x.isalpha() or "_", cstr(label).replace(" ", "_")) + [c for c in cstr(label).replace(" ", "_") if c.isdigit() or c.isalpha() or c == "_"] ) + self.fieldname = f"custom_{self.fieldname}" # fieldnames should be lowercase self.fieldname = self.fieldname.lower() diff --git a/frappe/custom/doctype/customize_form/customize_form.js b/frappe/custom/doctype/customize_form/customize_form.js index 8549c239e5..bd711c169d 100644 --- a/frappe/custom/doctype/customize_form/customize_form.js +++ b/frappe/custom/doctype/customize_form/customize_form.js @@ -49,14 +49,6 @@ frappe.ui.form.on("Customize Form", { grid_row.row.addClass("highlight"); } }); - - $(frm.wrapper).on("grid-make-sortable", function (e, frm) { - frm.trigger("setup_sortable"); - }); - - $(frm.wrapper).on("grid-move-row", function (e, frm) { - frm.trigger("setup_sortable"); - }); }, doc_type: function (frm) { @@ -71,7 +63,7 @@ frappe.ui.form.on("Customize Form", { frm.set_value("doc_type", ""); } else { frm.refresh(); - frm.trigger("setup_sortable"); + frm.trigger("add_customize_child_table_button"); frm.trigger("setup_default_views"); } } @@ -87,23 +79,16 @@ frappe.ui.form.on("Customize Form", { frm.trigger("setup_default_views"); }, - setup_sortable: function (frm) { + add_customize_child_table_button: function (frm) { frm.doc.fields.forEach(function (f) { - if (!f.is_custom_field || f.is_system_generated) { - f._sortable = false; - } + if (!in_list(["Table", "Table MultiSelect"], f.fieldtype)) return; - if (f.fieldtype == "Table") { - frm.add_custom_button( - f.options, - function () { - frm.set_value("doc_type", f.options); - }, - __("Customize Child Table") - ); - } + frm.add_custom_button( + f.options, + () => frm.set_value("doc_type", f.options), + __("Customize Child Table") + ); }); - frm.fields_dict.fields.grid.refresh(); }, refresh: function (frm) { @@ -125,6 +110,14 @@ frappe.ui.form.on("Customize Form", { __("Actions") ); + frm.add_custom_button( + __("Set Permissions"), + function () { + frappe.set_route("permission-manager", frm.doc.doc_type); + }, + __("Actions") + ); + frm.add_custom_button( __("Reload"), function () { @@ -134,17 +127,17 @@ frappe.ui.form.on("Customize Form", { ); frm.add_custom_button( - __("Reset to defaults"), - function () { - frappe.customize_form.confirm(__("Remove all customizations?"), frm); + __("Reset Layout"), + () => { + frm.trigger("reset_layout"); }, __("Actions") ); frm.add_custom_button( - __("Set Permissions"), + __("Reset All Customizations"), function () { - frappe.set_route("permission-manager", frm.doc.doc_type); + frappe.customize_form.confirm(__("Remove all customizations?"), frm); }, __("Actions") ); @@ -179,6 +172,27 @@ frappe.ui.form.on("Customize Form", { } }, + reset_layout(frm) { + frappe.confirm( + __("Layout will be reset to standard layout, are you sure you want to do this?"), + () => { + return frm.call({ + doc: frm.doc, + method: "reset_layout", + callback: function (r) { + if (!r.exc) { + frappe.show_alert({ + message: __("Layout Reset"), + indicator: "green", + }); + frappe.customize_form.clear_locals_and_refresh(frm); + } + }, + }); + } + ); + }, + setup_export(frm) { if (frappe.boot.developer_mode) { frm.add_custom_button( diff --git a/frappe/custom/doctype/customize_form/customize_form.py b/frappe/custom/doctype/customize_form/customize_form.py index 9aa61869d3..868a913ce3 100644 --- a/frappe/custom/doctype/customize_form/customize_form.py +++ b/frappe/custom/doctype/customize_form/customize_form.py @@ -35,7 +35,7 @@ class CustomizeForm(Document): if not self.doc_type: return - meta = frappe.get_meta(self.doc_type) + meta = frappe.get_meta(self.doc_type, cached=False) self.validate_doctype(meta) @@ -214,11 +214,39 @@ class CustomizeForm(Document): # action and links self.set_property_setters_for_actions_and_links(meta) + def set_property_setter_for_field_order(self, meta): + new_order = [df.fieldname for df in self.fields] + existing_order = getattr(meta, "field_order", None) + default_order = [ + fieldname for fieldname, df in meta._fields.items() if not getattr(df, "is_custom_field", False) + ] + + if new_order == default_order: + if existing_order: + delete_property_setter(self.doc_type, "field_order") + + return + + if existing_order and new_order == json.loads(existing_order): + return + + frappe.make_property_setter( + { + "doctype": self.doc_type, + "doctype_or_field": "DocType", + "property": "field_order", + "value": json.dumps(new_order), + }, + is_system_generated=False, + ) + def set_property_setters_for_doctype(self, meta): for prop, prop_type in doctype_properties.items(): if self.get(prop) != meta.get(prop): self.make_property_setter(prop, self.get(prop), prop_type) + self.set_property_setter_for_field_order(meta) + def set_property_setters_for_docfield(self, meta, df, meta_df): for prop, prop_type in docfield_properties.items(): if prop != "idx" and (df.get(prop) or "") != (meta_df[0].get(prop) or ""): @@ -540,6 +568,24 @@ class CustomizeForm(Document): reset_customization(self.doc_type) self.fetch_to_customize() + @frappe.whitelist() + def reset_layout(self): + if not self.doc_type: + return + + property_setters = frappe.get_all( + "Property Setter", + filters={"doc_type": self.doc_type, "property": ("in", ("field_order", "insert_after"))}, + pluck="name", + ) + + if not property_setters: + return + + frappe.db.delete("Property Setter", {"name": ("in", property_setters)}) + frappe.clear_cache(doctype=self.doc_type) + self.fetch_to_customize() + @classmethod def allow_fieldtype_change(self, old_type: str, new_type: str) -> bool: """allow type change, if both old_type and new_type are in same field group. @@ -619,6 +665,7 @@ docfield_properties = { "label": "Data", "fieldtype": "Select", "options": "Text", + "sort_options": "Check", "fetch_from": "Small Text", "fetch_if_empty": "Check", "show_dashboard": "Check", diff --git a/frappe/custom/doctype/customize_form/test_customize_form.py b/frappe/custom/doctype/customize_form/test_customize_form.py index 8d98dc4149..8a62d331be 100644 --- a/frappe/custom/doctype/customize_form/test_customize_form.py +++ b/frappe/custom/doctype/customize_form/test_customize_form.py @@ -14,10 +14,11 @@ test_dependencies = ["Custom Field", "Property Setter"] class TestCustomizeForm(FrappeTestCase): def insert_custom_field(self): - frappe.delete_doc_if_exists("Custom Field", "Event-test_custom_field") - frappe.get_doc( + frappe.delete_doc_if_exists("Custom Field", "Event-custom_test_field") + self.field = frappe.get_doc( { "doctype": "Custom Field", + "fieldname": "custom_test_field", "dt": "Event", "label": "Test Custom Field", "description": "A Custom Field for Testing", @@ -36,7 +37,7 @@ class TestCustomizeForm(FrappeTestCase): frappe.clear_cache(doctype="Event") def tearDown(self): - frappe.delete_doc("Custom Field", "Event-test_custom_field") + frappe.delete_doc("Custom Field", self.field.name) frappe.db.commit() frappe.clear_cache(doctype="Event") @@ -60,7 +61,7 @@ class TestCustomizeForm(FrappeTestCase): self.assertEqual(d.doc_type, "Event") self.assertEqual(len(d.get("fields")), len(frappe.get_doc("DocType", d.doc_type).fields) + 1) - self.assertEqual(d.get("fields")[-1].fieldname, "test_custom_field") + self.assertEqual(d.get("fields")[-1].fieldname, self.field.fieldname) self.assertEqual(d.get("fields", {"fieldname": "event_type"})[0].in_list_view, 1) return d @@ -129,21 +130,21 @@ class TestCustomizeForm(FrappeTestCase): def test_save_customization_custom_field_property(self): d = self.get_customize_form("Event") - self.assertEqual(frappe.db.get_value("Custom Field", "Event-test_custom_field", "reqd"), 0) + self.assertEqual(frappe.db.get_value("Custom Field", self.field.name, "reqd"), 0) - custom_field = d.get("fields", {"fieldname": "test_custom_field"})[0] + custom_field = d.get("fields", {"fieldname": self.field.fieldname})[0] custom_field.reqd = 1 custom_field.no_copy = 1 d.run_method("save_customization") - self.assertEqual(frappe.db.get_value("Custom Field", "Event-test_custom_field", "reqd"), 1) - self.assertEqual(frappe.db.get_value("Custom Field", "Event-test_custom_field", "no_copy"), 1) + self.assertEqual(frappe.db.get_value("Custom Field", self.field.name, "reqd"), 1) + self.assertEqual(frappe.db.get_value("Custom Field", self.field.name, "no_copy"), 1) custom_field = d.get("fields", {"is_custom_field": True})[0] custom_field.reqd = 0 custom_field.no_copy = 0 d.run_method("save_customization") - self.assertEqual(frappe.db.get_value("Custom Field", "Event-test_custom_field", "reqd"), 0) - self.assertEqual(frappe.db.get_value("Custom Field", "Event-test_custom_field", "no_copy"), 0) + self.assertEqual(frappe.db.get_value("Custom Field", self.field.name, "reqd"), 0) + self.assertEqual(frappe.db.get_value("Custom Field", self.field.name, "no_copy"), 0) def test_save_customization_new_field(self): d = self.get_customize_form("Event") @@ -157,28 +158,24 @@ class TestCustomizeForm(FrappeTestCase): }, ) d.run_method("save_customization") + + custom_field_name = "Event-custom_test_add_custom_field_via_customize_form" self.assertEqual( - frappe.db.get_value( - "Custom Field", "Event-test_add_custom_field_via_customize_form", "fieldtype" - ), + frappe.db.get_value("Custom Field", custom_field_name, "fieldtype"), "Data", ) self.assertEqual( - frappe.db.get_value( - "Custom Field", "Event-test_add_custom_field_via_customize_form", "insert_after" - ), + frappe.db.get_value("Custom Field", custom_field_name, "insert_after"), last_fieldname, ) - frappe.delete_doc("Custom Field", "Event-test_add_custom_field_via_customize_form") - self.assertEqual( - frappe.db.get_value("Custom Field", "Event-test_add_custom_field_via_customize_form"), None - ) + frappe.delete_doc("Custom Field", custom_field_name) + self.assertEqual(frappe.db.get_value("Custom Field", custom_field_name), None) def test_save_customization_remove_field(self): d = self.get_customize_form("Event") - custom_field = d.get("fields", {"fieldname": "test_custom_field"})[0] + custom_field = d.get("fields", {"fieldname": self.field.fieldname})[0] d.get("fields").remove(custom_field) d.run_method("save_customization") @@ -200,7 +197,7 @@ class TestCustomizeForm(FrappeTestCase): def test_set_allow_on_submit(self): d = self.get_customize_form("Event") d.get("fields", {"fieldname": "subject"})[0].allow_on_submit = 1 - d.get("fields", {"fieldname": "test_custom_field"})[0].allow_on_submit = 1 + d.get("fields", {"fieldname": "custom_test_field"})[0].allow_on_submit = 1 d.run_method("save_customization") d = self.get_customize_form("Event") @@ -209,7 +206,7 @@ class TestCustomizeForm(FrappeTestCase): self.assertEqual(d.get("fields", {"fieldname": "subject"})[0].allow_on_submit or 0, 0) # allow for custom field - self.assertEqual(d.get("fields", {"fieldname": "test_custom_field"})[0].allow_on_submit, 1) + self.assertEqual(d.get("fields", {"fieldname": "custom_test_field"})[0].allow_on_submit, 1) def test_title_field_pattern(self): d = self.get_customize_form("Web Form") @@ -406,7 +403,7 @@ class TestCustomizeForm(FrappeTestCase): def test_system_generated_fields(self): doctype = "Event" - custom_field_name = "test_custom_field" + custom_field_name = "custom_test_field" custom_field = frappe.get_doc("Custom Field", {"dt": doctype, "fieldname": custom_field_name}) custom_field.is_system_generated = 1 @@ -425,3 +422,15 @@ class TestCustomizeForm(FrappeTestCase): self.assertEqual( frappe.db.get_value("Property Setter", property_setter_filters, "value"), "Test Description" ) + + def test_custom_field_order(self): + # shuffle fields + customize_form = self.get_customize_form(doctype="ToDo") + customize_form.fields.insert(0, customize_form.fields.pop()) + customize_form.save_customization() + + field_order_property = json.loads( + frappe.db.get_value("Property Setter", {"doc_type": "ToDo", "property": "field_order"}, "value") + ) + + self.assertEqual(field_order_property, [df.fieldname for df in frappe.get_meta("ToDo").fields]) diff --git a/frappe/custom/doctype/customize_form_field/customize_form_field.json b/frappe/custom/doctype/customize_form_field/customize_form_field.json index d8da44101b..4127598291 100644 --- a/frappe/custom/doctype/customize_form_field/customize_form_field.json +++ b/frappe/custom/doctype/customize_form_field/customize_form_field.json @@ -29,6 +29,7 @@ "precision", "length", "options", + "sort_options", "fetch_from", "fetch_if_empty", "show_dashboard", @@ -89,7 +90,8 @@ "oldfieldtype": "Select", "options": "Autocomplete\nAttach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nPhone\nRating\nRead Only\nSection Break\nSelect\nSignature\nSmall Text\nTab Break\nTable\nTable MultiSelect\nText\nText Editor\nTime", "reqd": 1, - "search_index": 1 + "search_index": 1, + "sort_options": 1 }, { "fieldname": "fieldname", @@ -462,13 +464,20 @@ "fieldname": "ignore_xss_filter", "fieldtype": "Check", "label": "Ignore XSS Filter" + }, + { + "default": "0", + "depends_on": "eval: doc.fieldtype === 'Select'", + "fieldname": "sort_options", + "fieldtype": "Check", + "label": "Sort Options" } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2023-02-20 12:07:40.242470", + "modified": "2023-06-08 19:05:37.767838", "modified_by": "Administrator", "module": "Custom", "name": "Customize Form Field", diff --git a/frappe/database/database.py b/frappe/database/database.py index 2bac5a1ffc..a264f39d47 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -17,7 +17,6 @@ from pypika.terms import Criterion, NullValue import frappe import frappe.defaults -import frappe.model.meta from frappe import _ from frappe.database.utils import ( DefaultOrderBy, @@ -33,7 +32,7 @@ from frappe.query_builder.functions import Count from frappe.utils import CallbackManager 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, deprecation_warning +from frappe.utils.deprecations import deprecation_warning IFNULL_PATTERN = re.compile(r"ifnull\(", flags=re.IGNORECASE) INDEX_PATTERN = re.compile(r"\s*\([^)]+\)\s*") @@ -302,7 +301,7 @@ class Database: """Takes the query and logs it to various interfaces according to the settings.""" _query = None - if frappe.conf.allow_tests and frappe.cache().get_value("flag_print_sql"): + if frappe.conf.allow_tests and frappe.cache.get_value("flag_print_sql"): _query = _query or str(mogrified_query) print(_query) @@ -419,7 +418,7 @@ class Database: @staticmethod def clear_db_table_cache(query): if query and is_query_type(query, ("drop", "create")): - frappe.cache().delete_key("db_tables") + frappe.cache.delete_key("db_tables") def get_description(self): """Returns result metadata.""" @@ -874,7 +873,6 @@ class Database: modified_by=None, update_modified=True, debug=False, - for_update=True, ): """Set a single value in the database, do not call the ORM triggers but update the modified timestamp (unless specified not to). @@ -890,10 +888,11 @@ 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. """ + from frappe.model.utils import is_single_doctype - if _is_single_doctype := not (dn and dt != dn): + if (dn is None or dt == dn) and is_single_doctype(dt): 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." + "Calling db.set_value on single doctype is deprecated. This behaviour will be removed in future. Use db.set_single_value instead." ) self.set_single_value( doctype=dt, @@ -1067,7 +1066,7 @@ class Database: def count(self, dt, filters=None, debug=False, cache=False, distinct: bool = True): """Returns `COUNT(*)` for given DocType and filters.""" if cache and not filters: - cache_count = frappe.cache().get_value(f"doctype:count:{dt}") + cache_count = frappe.cache.get_value(f"doctype:count:{dt}") if cache_count is not None: return cache_count count = frappe.qb.get_query( @@ -1078,7 +1077,7 @@ class Database: validate_filters=True, ).run(debug=debug)[0][0] if not filters and cache: - frappe.cache().set_value(f"doctype:count:{dt}", count, expires_in_sec=86400) + frappe.cache.set_value(f"doctype:count:{dt}", count, expires_in_sec=86400) return count @staticmethod @@ -1109,7 +1108,7 @@ class Database: def get_db_table_columns(self, table) -> list[str]: """Returns list of column names from given table.""" - columns = frappe.cache().hget("table_columns", table) + columns = frappe.cache.hget("table_columns", table) if columns is None: information_schema = frappe.qb.Schema("information_schema") @@ -1121,7 +1120,7 @@ class Database: ) if columns: - frappe.cache().hset("table_columns", table, columns) + frappe.cache.hset("table_columns", table, columns) return columns diff --git a/frappe/database/mariadb/database.py b/frappe/database/mariadb/database.py index f14fce2710..6a89966ee5 100644 --- a/frappe/database/mariadb/database.py +++ b/frappe/database/mariadb/database.py @@ -435,7 +435,7 @@ class MariaDBDatabase(MariaDBConnectionUtil, MariaDBExceptionUtil, Database): to_query = not cached if cached: - tables = frappe.cache().get_value("db_tables") + tables = frappe.cache.get_value("db_tables") to_query = not tables if to_query: @@ -447,7 +447,7 @@ class MariaDBDatabase(MariaDBConnectionUtil, MariaDBExceptionUtil, Database): .where(information_schema.tables.table_schema != "information_schema") .run(pluck=True) ) - frappe.cache().set_value("db_tables", tables) + frappe.cache.set_value("db_tables", tables) return tables diff --git a/frappe/database/schema.py b/frappe/database/schema.py index e65d7b980b..ed7d1d16fc 100644 --- a/frappe/database/schema.py +++ b/frappe/database/schema.py @@ -40,7 +40,7 @@ class DBTable: if self.is_new(): self.create() else: - frappe.cache().hdel("table_columns", self.table_name) + frappe.cache.hdel("table_columns", self.table_name) self.alter() def create(self): diff --git a/frappe/defaults.py b/frappe/defaults.py index edbf784200..3bcfbec1ce 100644 --- a/frappe/defaults.py +++ b/frappe/defaults.py @@ -3,7 +3,6 @@ import frappe from frappe.cache_manager import clear_defaults_cache, common_default_keys -from frappe.desk.notifications import clear_notifications from frappe.query_builder import DocType # Note: DefaultValue records are identified by parent (e.g. __default, __global) @@ -230,7 +229,7 @@ def clear_default(key=None, value=None, parent=None, name=None, parenttype=None) def get_defaults_for(parent="__default"): """get all defaults""" - defaults = frappe.cache().hget("defaults", parent) + defaults = frappe.cache.hget("defaults", parent) if defaults is None: # sort descending because first default must get precedence @@ -256,7 +255,7 @@ def get_defaults_for(parent="__default"): elif d.defvalue is not None: defaults[d.defkey] = d.defvalue - frappe.cache().hset("defaults", parent, defaults) + frappe.cache.hset("defaults", parent, defaults) return defaults diff --git a/frappe/deferred_insert.py b/frappe/deferred_insert.py index 328d8dd555..5c7e7a7f0d 100644 --- a/frappe/deferred_insert.py +++ b/frappe/deferred_insert.py @@ -19,20 +19,20 @@ def deferred_insert(doctype: str, records: list[Union[dict, "Document"]] | str): _records = records try: - frappe.cache().rpush(f"{queue_prefix}{doctype}", _records) + frappe.cache.rpush(f"{queue_prefix}{doctype}", _records) except redis.exceptions.ConnectionError: for record in records: insert_record(record, doctype) def save_to_db(): - queue_keys = frappe.cache().get_keys(queue_prefix) + queue_keys = frappe.cache.get_keys(queue_prefix) for key in queue_keys: record_count = 0 queue_key = get_key_name(key) doctype = get_doctype_name(key) - while frappe.cache().llen(queue_key) > 0 and record_count <= 500: - records = frappe.cache().lpop(queue_key) + while frappe.cache.llen(queue_key) > 0 and record_count <= 500: + records = frappe.cache.lpop(queue_key) records = json.loads(records.decode("utf-8")) if isinstance(records, dict): record_count += 1 diff --git a/frappe/desk/desktop.py b/frappe/desk/desktop.py index 46cda8fe5d..cf9f223d2a 100644 --- a/frappe/desk/desktop.py +++ b/frappe/desk/desktop.py @@ -62,10 +62,10 @@ class Workspace: self.table_counts = get_table_with_counts() self.restricted_doctypes = ( - frappe.cache().get_value("domain_restricted_doctypes") or build_domain_restriced_doctype_cache() + frappe.cache.get_value("domain_restricted_doctypes") or build_domain_restriced_doctype_cache() ) self.restricted_pages = ( - frappe.cache().get_value("domain_restricted_pages") or build_domain_restriced_page_cache() + frappe.cache.get_value("domain_restricted_pages") or build_domain_restriced_page_cache() ) def is_permitted(self): @@ -88,16 +88,14 @@ class Workspace: return True def get_cached(self, cache_key, fallback_fn): - _cache = frappe.cache() - - value = _cache.get_value(cache_key, user=frappe.session.user) + value = frappe.cache.get_value(cache_key, user=frappe.session.user) if value: return value value = fallback_fn() # Expire every six hour - _cache.set_value(cache_key, value, frappe.session.user, 21600) + frappe.cache.set_value(cache_key, value, frappe.session.user, 21600) return value def get_can_read_items(self): @@ -469,7 +467,7 @@ def get_workspace_sidebar_items(): def get_table_with_counts(): - counts = frappe.cache().get_value("information_schema:counts") + counts = frappe.cache.get_value("information_schema:counts") if not counts: counts = build_table_count_cache() diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py index 5cbeb06e33..16f4efea9d 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py @@ -340,7 +340,7 @@ def get_charts_for_user(doctype, txt, searchfield, start, page_len, filters): class DashboardChart(Document): def on_update(self): - frappe.cache().delete_key(f"chart-data:{self.name}") + frappe.cache.delete_key(f"chart-data:{self.name}") if frappe.conf.developer_mode and self.is_standard: export_to_files(record_list=[["Dashboard Chart", self.name]], record_module=self.module) diff --git a/frappe/desk/doctype/desktop_icon/desktop_icon.py b/frappe/desk/doctype/desktop_icon/desktop_icon.py index 63fa12b8fb..0d6e5bb815 100644 --- a/frappe/desk/doctype/desktop_icon/desktop_icon.py +++ b/frappe/desk/doctype/desktop_icon/desktop_icon.py @@ -28,7 +28,7 @@ def get_desktop_icons(user=None): if not user: user = frappe.session.user - user_icons = frappe.cache().hget("desktop_icons", user) + user_icons = frappe.cache.hget("desktop_icons", user) if not user_icons: fields = [ @@ -120,7 +120,7 @@ def get_desktop_icons(user=None): if d.label: d.label = _(d.label) - frappe.cache().hset("desktop_icons", user, user_icons) + frappe.cache.hset("desktop_icons", user, user_icons) return user_icons @@ -313,8 +313,8 @@ def get_all_icons(): def clear_desktop_icons_cache(user=None): - frappe.cache().hdel("desktop_icons", user or frappe.session.user) - frappe.cache().hdel("bootinfo", user or frappe.session.user) + frappe.cache.hdel("desktop_icons", user or frappe.session.user) + frappe.cache.hdel("bootinfo", user or frappe.session.user) def get_user_copy(module_name, user=None): @@ -445,7 +445,7 @@ def get_module_icons(user=None): if not user: icons = frappe.get_all("Desktop Icon", fields="*", filters={"standard": 1}, order_by="idx") else: - frappe.cache().hdel("desktop_icons", user) + frappe.cache.hdel("desktop_icons", user) icons = get_user_icons(user) for icon in icons: diff --git a/frappe/desk/doctype/form_tour/form_tour.py b/frappe/desk/doctype/form_tour/form_tour.py index 6838f15d8f..bdfdcf7c79 100644 --- a/frappe/desk/doctype/form_tour/form_tour.py +++ b/frappe/desk/doctype/form_tour/form_tour.py @@ -34,13 +34,13 @@ class FormTour(Document): step.fieldtype = field_df.fieldtype def on_update(self): - frappe.cache().delete_key("bootinfo") + frappe.cache.delete_key("bootinfo") if frappe.conf.developer_mode and self.is_standard: export_to_files([["Form Tour", self.name]], self.module) def on_trash(self): - frappe.cache().delete_key("bootinfo") + frappe.cache.delete_key("bootinfo") @frappe.whitelist() @@ -51,7 +51,7 @@ def reset_tour(tour_name): frappe.db.set_value( "User", user, "onboarding_status", frappe.as_json(onboarding_status), update_modified=False ) - frappe.cache().hdel("bootinfo", user) + frappe.cache.hdel("bootinfo", user) frappe.msgprint(_("Successfully reset onboarding status for all users."), alert=True) @@ -72,7 +72,7 @@ def update_user_status(value, step): "User", frappe.session.user, "onboarding_status", value, update_modified=False ) - frappe.cache().hdel("bootinfo", frappe.session.user) + frappe.cache.hdel("bootinfo", frappe.session.user) def get_onboarding_ui_tours(): diff --git a/frappe/desk/doctype/global_search_settings/global_search_settings.py b/frappe/desk/doctype/global_search_settings/global_search_settings.py index 4e2b1e85f9..f0bf985550 100644 --- a/frappe/desk/doctype/global_search_settings/global_search_settings.py +++ b/frappe/desk/doctype/global_search_settings/global_search_settings.py @@ -28,7 +28,7 @@ class GlobalSearchSettings(Document): frappe.throw(_("Document Type {0} has been repeated.").format(repeated_dts)) # reset cache - frappe.cache().hdel("global_search", "search_priorities") + frappe.cache.hdel("global_search", "search_priorities") def get_doctypes_for_global_search(): @@ -36,7 +36,7 @@ def get_doctypes_for_global_search(): doctypes = frappe.get_all("Global Search DocType", fields=["document_type"], order_by="idx ASC") return [d.document_type for d in doctypes] or [] - return frappe.cache().hget("global_search", "search_priorities", get_from_db) + return frappe.cache.hget("global_search", "search_priorities", get_from_db) @frappe.whitelist() diff --git a/frappe/desk/doctype/kanban_board/kanban_board.py b/frappe/desk/doctype/kanban_board/kanban_board.py index e3257e25be..508407f76a 100644 --- a/frappe/desk/doctype/kanban_board/kanban_board.py +++ b/frappe/desk/doctype/kanban_board/kanban_board.py @@ -14,7 +14,7 @@ class KanbanBoard(Document): def on_change(self): frappe.clear_cache(doctype=self.reference_doctype) - frappe.cache().delete_keys("_user_settings") + frappe.cache.delete_keys("_user_settings") def before_insert(self): for column in self.columns: diff --git a/frappe/desk/doctype/module_onboarding/module_onboarding.js b/frappe/desk/doctype/module_onboarding/module_onboarding.js index 0e312025bf..831b29a660 100644 --- a/frappe/desk/doctype/module_onboarding/module_onboarding.js +++ b/frappe/desk/doctype/module_onboarding/module_onboarding.js @@ -13,6 +13,10 @@ frappe.ui.form.on("Module Onboarding", { if (!frappe.boot.developer_mode) { frm.trigger("disable_form"); } + + frm.add_custom_button(__("Reset"), () => { + frm.call("reset_progress"); + }); }, disable_form: function (frm) { diff --git a/frappe/desk/doctype/module_onboarding/module_onboarding.py b/frappe/desk/doctype/module_onboarding/module_onboarding.py index ea02f5911d..94805d05b6 100644 --- a/frappe/desk/doctype/module_onboarding/module_onboarding.py +++ b/frappe/desk/doctype/module_onboarding/module_onboarding.py @@ -2,6 +2,7 @@ # License: MIT. See LICENSE import frappe +from frappe import _ from frappe.model.document import Document from frappe.modules.export_file import export_to_files @@ -37,6 +38,16 @@ class ModuleOnboarding(Document): return False + @frappe.whitelist() + def reset_progress(self): + self.db_set("is_complete", 0) + + for step in self.get_steps(): + step.db_set("is_complete", 0) + step.db_set("is_skipped", 0) + + frappe.msgprint(_("Module onboarding progress reset"), alert=True) + def before_export(self, doc): doc.is_complete = 0 diff --git a/frappe/desk/doctype/notification_log/notification_log.json b/frappe/desk/doctype/notification_log/notification_log.json index f24a6447b4..bafe28faf8 100644 --- a/frappe/desk/doctype/notification_log/notification_log.json +++ b/frappe/desk/doctype/notification_log/notification_log.json @@ -29,7 +29,8 @@ "fieldtype": "Link", "hidden": 1, "label": "For User", - "options": "User" + "options": "User", + "search_index": 1 }, { "fieldname": "type", @@ -64,8 +65,7 @@ "fieldtype": "Link", "hidden": 1, "label": "From User", - "options": "User", - "search_index": 1 + "options": "User" }, { "default": "0", @@ -96,7 +96,7 @@ "hide_toolbar": 1, "in_create": 1, "links": [], - "modified": "2022-09-13 16:08:48.153934", + "modified": "2023-06-14 21:20:51.197943", "modified_by": "Administrator", "module": "Desk", "name": "Notification Log", diff --git a/frappe/desk/doctype/todo/todo.py b/frappe/desk/doctype/todo/todo.py index e076f3384a..46a826688a 100644 --- a/frappe/desk/doctype/todo/todo.py +++ b/frappe/desk/doctype/todo/todo.py @@ -81,13 +81,21 @@ class ToDo(Document): ) assignments.reverse() - frappe.db.set_value( - self.reference_type, - self.reference_name, - "_assign", - json.dumps(assignments), - update_modified=False, - ) + if frappe.get_meta(self.reference_type).issingle: + frappe.db.set_single_value( + self.reference_type, + "_assign", + json.dumps(assignments), + update_modified=False, + ) + else: + frappe.db.set_value( + self.reference_type, + self.reference_name, + "_assign", + json.dumps(assignments), + update_modified=False, + ) except Exception as e: if frappe.db.is_table_missing(e) and frappe.flags.in_install: diff --git a/frappe/desk/doctype/workspace/workspace.json b/frappe/desk/doctype/workspace/workspace.json index 2759acd228..0769b2a81b 100644 --- a/frappe/desk/doctype/workspace/workspace.json +++ b/frappe/desk/doctype/workspace/workspace.json @@ -211,7 +211,7 @@ ], "in_create": 1, "links": [], - "modified": "2023-05-17 14:52:38.110224", + "modified": "2023-06-08 14:52:38.110224", "modified_by": "Administrator", "module": "Desk", "name": "Workspace", diff --git a/frappe/desk/form/linked_with.py b/frappe/desk/form/linked_with.py index 9bc7b138dd..1ec604c34d 100644 --- a/frappe/desk/form/linked_with.py +++ b/frappe/desk/form/linked_with.py @@ -531,13 +531,13 @@ def get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False): {"Address": {"fieldname": "customer"}..} """ if without_ignore_user_permissions_enabled: - return frappe.cache().hget( + return frappe.cache.hget( "linked_doctypes_without_ignore_user_permissions_enabled", doctype, lambda: _get_linked_doctypes(doctype, without_ignore_user_permissions_enabled), ) else: - return frappe.cache().hget("linked_doctypes", doctype, lambda: _get_linked_doctypes(doctype)) + return frappe.cache.hget("linked_doctypes", doctype, lambda: _get_linked_doctypes(doctype)) def _get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False): diff --git a/frappe/desk/form/load.py b/frappe/desk/form/load.py index 42109f8863..e6f735e9d7 100644 --- a/frappe/desk/form/load.py +++ b/frappe/desk/form/load.py @@ -80,7 +80,7 @@ def get_meta_bundle(doctype): bundle = [frappe.desk.form.meta.get_meta(doctype)] for df in bundle[0].fields: if df.fieldtype in frappe.model.table_fields: - bundle.append(frappe.desk.form.meta.get_meta(df.options, not frappe.conf.developer_mode)) + bundle.append(frappe.desk.form.meta.get_meta(df.options)) return bundle @@ -202,11 +202,13 @@ def get_versions(doc): @frappe.whitelist() def get_communications(doctype, name, start=0, limit=20): + from frappe.utils import cint + doc = frappe.get_doc(doctype, name) if not doc.has_permission("read"): raise frappe.PermissionError - return _get_communications(doctype, name, start, limit) + return _get_communications(doctype, name, cint(start), cint(limit)) def get_comments( diff --git a/frappe/desk/form/meta.py b/frappe/desk/form/meta.py index 62a9c89c81..6c338dbbbc 100644 --- a/frappe/desk/form/meta.py +++ b/frappe/desk/form/meta.py @@ -9,7 +9,6 @@ from frappe.build import scrub_html_template from frappe.model.meta import Meta from frappe.model.utils import render_include from frappe.modules import get_module_path, load_doctype_module, scrub -from frappe.translate import extract_messages_from_code, make_dict_from_messages from frappe.utils import get_html_format from frappe.utils.data import get_link_to_form @@ -34,13 +33,15 @@ ASSET_KEYS = ( ) -def get_meta(doctype, cached=True): +def get_meta(doctype, cached=True) -> "FormMeta": # don't cache for developer mode as js files, templates may be edited - if cached and not frappe.conf.developer_mode: - meta = frappe.cache().hget("doctype_form_meta", doctype) + cached = cached and not frappe.conf.developer_mode + if cached: + meta = frappe.cache.hget("doctype_form_meta", doctype) if not meta: - meta = FormMeta(doctype) - frappe.cache().hset("doctype_form_meta", doctype, meta) + # Cache miss - explicitly get meta from DB to avoid + meta = FormMeta(doctype, cached=False) + frappe.cache.hset("doctype_form_meta", doctype, meta) else: meta = FormMeta(doctype) @@ -51,8 +52,8 @@ def get_meta(doctype, cached=True): class FormMeta(Meta): - def __init__(self, doctype): - self.__dict__.update(frappe.get_meta(doctype).__dict__) + def __init__(self, doctype, *, cached=True): + self.__dict__.update(frappe.get_meta(doctype, cached=cached).__dict__) self.load_assets() def load_assets(self): @@ -258,6 +259,8 @@ class FormMeta(Meta): self.set("__form_grid_templates", templates) def set_translations(self, lang): + from frappe.translate import extract_messages_from_code, make_dict_from_messages + self.set("__messages", frappe.get_lang_dict("doctype", self.name)) # set translations for grid templates diff --git a/frappe/desk/form/save.py b/frappe/desk/form/save.py index 75335cb1ce..180717da40 100644 --- a/frappe/desk/form/save.py +++ b/frappe/desk/form/save.py @@ -16,7 +16,7 @@ from frappe.utils.telemetry import capture_doc def savedocs(doc, action): """save / submit / update doclist""" doc = frappe.get_doc(json.loads(doc)) - capture_doc(doc) + capture_doc(doc, action) set_local_name(doc) # action @@ -47,6 +47,8 @@ def savedocs(doc, action): def cancel(doctype=None, name=None, workflow_state_fieldname=None, workflow_state=None): """cancel a doclist""" doc = frappe.get_doc(doctype, name) + capture_doc(doc, "Cancel") + if workflow_state_fieldname and workflow_state: doc.set(workflow_state_fieldname, workflow_state) doc.cancel() diff --git a/frappe/desk/like.py b/frappe/desk/like.py index 0f297455e7..60eea3c525 100644 --- a/frappe/desk/like.py +++ b/frappe/desk/like.py @@ -52,7 +52,10 @@ def _toggle_like(doctype, name, add, user=None): liked_by.remove(user) remove_like(doctype, name) - frappe.db.set_value(doctype, name, "_liked_by", json.dumps(liked_by), update_modified=False) + if frappe.get_meta(doctype).issingle: + frappe.db.set_single_value(doctype, "_liked_by", json.dumps(liked_by), update_modified=False) + else: + frappe.db.set_value(doctype, name, "_liked_by", json.dumps(liked_by), update_modified=False) except frappe.db.ProgrammingError as e: if frappe.db.is_column_missing(e): diff --git a/frappe/desk/notifications.py b/frappe/desk/notifications.py index 271f2b4074..6334b18d1c 100644 --- a/frappe/desk/notifications.py +++ b/frappe/desk/notifications.py @@ -34,13 +34,12 @@ def get_notifications(): return out groups = list(config.get("for_doctype")) + list(config.get("for_module")) - cache = frappe.cache() notification_count = {} notification_percent = {} for name in groups: - count = cache.hget("notification_count:" + name, frappe.session.user) + count = frappe.cache.hget("notification_count:" + name, frappe.session.user) if count is not None: notification_count[name] = count @@ -83,7 +82,7 @@ def get_notifications_for_doctypes(config, notification_count): else: open_count_doctype[d] = result - frappe.cache().hset("notification_count:" + d, frappe.session.user, result) + frappe.cache.hset("notification_count:" + d, frappe.session.user, result) return open_count_doctype @@ -139,7 +138,6 @@ def get_notifications_for_targets(config, notification_percent): def clear_notifications(user=None): if frappe.flags.in_install: return - cache = frappe.cache() config = get_notification_config() if not config: @@ -151,17 +149,17 @@ def clear_notifications(user=None): for name in groups: if user: - cache.hdel("notification_count:" + name, user) + frappe.cache.hdel("notification_count:" + name, user) else: - cache.delete_key("notification_count:" + name) + frappe.cache.delete_key("notification_count:" + name) def clear_notification_config(user): - frappe.cache().hdel("notification_config", user) + frappe.cache.hdel("notification_config", user) def delete_notification_count_for(doctype): - frappe.cache().delete_key("notification_count:" + doctype) + frappe.cache.delete_key("notification_count:" + doctype) def clear_doctype_notifications(doc, method=None, *args, **kwargs): @@ -230,7 +228,7 @@ def get_notification_config(): config[key].update(nc.get(key, {})) return config - return frappe.cache().hget("notification_config", user, _get) + return frappe.cache.hget("notification_config", user, _get) def get_filters_for(doctype): diff --git a/frappe/desk/page/setup_wizard/setup_wizard.py b/frappe/desk/page/setup_wizard/setup_wizard.py index cb869fb5fc..a50588bdca 100755 --- a/frappe/desk/page/setup_wizard/setup_wizard.py +++ b/frappe/desk/page/setup_wizard/setup_wizard.py @@ -325,8 +325,8 @@ def load_country(): @frappe.whitelist() def load_user_details(): return { - "full_name": frappe.cache().hget("full_name", "signup"), - "email": frappe.cache().hget("email", "signup"), + "full_name": frappe.cache.hget("full_name", "signup"), + "email": frappe.cache.hget("email", "signup"), } diff --git a/frappe/desk/query_report.py b/frappe/desk/query_report.py index 69cdecb6dd..3d54520356 100644 --- a/frappe/desk/query_report.py +++ b/frappe/desk/query_report.py @@ -119,7 +119,7 @@ def generate_report_result( "report_summary": report_summary, "skip_total_row": skip_total_row or 0, "status": None, - "execution_time": frappe.cache().hget("report_execution_time", report.name) or 0, + "execution_time": frappe.cache.hget("report_execution_time", report.name) or 0, } @@ -170,7 +170,8 @@ def get_script(report_name): return { "script": render_include(script), "html_format": html_format, - "execution_time": frappe.cache().hget("report_execution_time", report_name) or 0, + "execution_time": frappe.cache.hget("report_execution_time", report_name) or 0, + "filters": report.filters, } @@ -348,6 +349,13 @@ def build_xlsx_data(data, visible_idx, include_indentation, ignore_visible_idx=F datetime.timedelta, ) + if len(visible_idx) == len(data.result): + # It's not possible to have same length and different content. + ignore_visible_idx = True + else: + # Note: converted for faster lookups + visible_idx = set(visible_idx) + result = [[]] column_widths = [] diff --git a/frappe/desk/reportview.py b/frappe/desk/reportview.py index 326e9bb864..071b6e7e61 100644 --- a/frappe/desk/reportview.py +++ b/frappe/desk/reportview.py @@ -479,6 +479,7 @@ def delete_items(): def delete_bulk(doctype, items): + undeleted_items = [] for i, d in enumerate(items): try: frappe.delete_doc(doctype, d) @@ -493,7 +494,11 @@ def delete_bulk(doctype, items): except Exception: # rollback if any record failed to delete # if not rollbacked, queries get committed on after_request method in app.py + undeleted_items.append(d) frappe.db.rollback() + if undeleted_items and len(items) != len(undeleted_items): + frappe.clear_messages() + delete_bulk(doctype, undeleted_items) @frappe.whitelist() diff --git a/frappe/desk/search.py b/frappe/desk/search.py index 67695e4e73..c4c11558dd 100644 --- a/frappe/desk/search.py +++ b/frappe/desk/search.py @@ -6,7 +6,9 @@ import json import re import frappe -from frappe import _, is_whitelisted + +# Backward compatbility +from frappe import _, is_whitelisted, validate_and_sanitize_search_inputs from frappe.database.schema import SPECIAL_CHAR_PATTERN from frappe.permissions import has_permission from frappe.utils import cint, cstr, unique @@ -293,26 +295,10 @@ def relevance_sorter(key, query, as_dict): return (cstr(value).casefold().startswith(query.casefold()) is not True, value) -def validate_and_sanitize_search_inputs(fn): - @functools.wraps(fn) - def wrapper(*args, **kwargs): - kwargs.update(dict(zip(fn.__code__.co_varnames, args))) - sanitize_searchfield(kwargs["searchfield"]) - kwargs["start"] = cint(kwargs["start"]) - kwargs["page_len"] = cint(kwargs["page_len"]) - - if kwargs["doctype"] and not frappe.db.exists("DocType", kwargs["doctype"]): - return [] - - return fn(**kwargs) - - return wrapper - - @frappe.whitelist() def get_names_for_mentions(search_term): - users_for_mentions = frappe.cache().get_value("users_for_mentions", get_users_for_mentions) - user_groups = frappe.cache().get_value("user_groups", get_user_groups) + users_for_mentions = frappe.cache.get_value("users_for_mentions", get_users_for_mentions) + user_groups = frappe.cache.get_value("user_groups", get_user_groups) filtered_mentions = [] for mention_data in users_for_mentions + user_groups: diff --git a/frappe/email/__init__.py b/frappe/email/__init__.py index 486db2a784..5c4d6f4c72 100644 --- a/frappe/email/__init__.py +++ b/frappe/email/__init__.py @@ -96,7 +96,7 @@ def get_communication_doctype(doctype, txt, searchfield, start, page_len, filter def get_cached_contacts(txt): - contacts = frappe.cache().hget("contacts", frappe.session.user) or [] + contacts = frappe.cache.hget("contacts", frappe.session.user) or [] if not contacts: return @@ -113,9 +113,9 @@ def get_cached_contacts(txt): def update_contact_cache(contacts): - cached_contacts = frappe.cache().hget("contacts", frappe.session.user) or [] + cached_contacts = frappe.cache.hget("contacts", frappe.session.user) or [] uncached_contacts = [d for d in contacts if d not in cached_contacts] cached_contacts.extend(uncached_contacts) - frappe.cache().hset("contacts", frappe.session.user, cached_contacts) + frappe.cache.hset("contacts", frappe.session.user, cached_contacts) diff --git a/frappe/email/doctype/email_account/email_account.json b/frappe/email/doctype/email_account/email_account.json index 85241b8194..d61165b787 100644 --- a/frappe/email/doctype/email_account/email_account.json +++ b/frappe/email/doctype/email_account/email_account.json @@ -508,7 +508,7 @@ }, { "default": "0", - "depends_on": "eval:!doc.domain && doc.enable_outgoing", + "depends_on": "eval:!doc.domain && doc.enable_outgoing && doc.enable_incoming && doc.use_imap", "fieldname": "append_emails_to_sent_folder", "fieldtype": "Check", "hide_days": 1, @@ -616,7 +616,7 @@ "icon": "fa fa-inbox", "index_web_pages_for_search": 1, "links": [], - "modified": "2022-12-28 14:56:18.754804", + "modified": "2023-06-05 15:03:08.538819", "modified_by": "Administrator", "module": "Email", "name": "Email Account", @@ -639,4 +639,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} +} \ No newline at end of file diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py index faf28afdb3..3f6051ffc8 100755 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -176,7 +176,7 @@ class EmailAccount(Document): def get_incoming_server(self, in_receive=False, email_sync_rule="UNSEEN"): """Returns logged in POP3/IMAP connection object.""" - if frappe.cache().get_value("workers:no-internet") == True: + if frappe.cache.get_value("workers:no-internet") == True: return None oauth_token = self.get_oauth_token() @@ -253,7 +253,7 @@ class EmailAccount(Document): if self.no_failed > 2: self.handle_incoming_connect_error(description=description) else: - frappe.cache().set_value("workers:no-internet", True) + frappe.cache.set_value("workers:no-internet", True) return None else: raise @@ -384,6 +384,10 @@ class EmailAccount(Document): "name": {"conf_names": ("email_sender_name",), "default": "Frappe"}, "auth_method": {"conf_names": ("auth_method"), "default": "Basic"}, "from_site_config": {"default": True}, + "no_smtp_authentication": { + "conf_names": ("disable_mail_smtp_authentication",), + "default": 0, + }, } account_details = {} @@ -436,13 +440,13 @@ class EmailAccount(Document): else: self.set_failed_attempts_count(self.get_failed_attempts_count() + 1) else: - frappe.cache().set_value("workers:no-internet", True) + frappe.cache.set_value("workers:no-internet", True) def set_failed_attempts_count(self, value): - frappe.cache().set(f"{self.name}:email-account-failed-attempts", value) + frappe.cache.set(f"{self.name}:email-account-failed-attempts", value) def get_failed_attempts_count(self): - return cint(frappe.cache().get(f"{self.name}:email-account-failed-attempts")) + return cint(frappe.cache.get(f"{self.name}:email-account-failed-attempts")) def receive(self): """Called by scheduler to receive emails from this EMail account using POP3/IMAP.""" @@ -648,21 +652,16 @@ class EmailAccount(Document): frappe.throw(_("Automatic Linking can be activated only for one Email Account.")) def append_email_to_sent_folder(self, message): - email_server = None - try: - email_server = self.get_incoming_server(in_receive=True) - except Exception: - self.log_error("Email Connection Error") - - if not email_server: + if not (self.enable_incoming and self.use_imap): + # don't try appending if enable incoming and imap is not set return - if email_server.imap: - try: - message = safe_encode(message) - email_server.imap.append("Sent", "\\Seen", imaplib.Time2Internaldate(time.time()), message) - except Exception: - self.log_error("Unable to add to Sent folder") + try: + email_server = self.get_incoming_server(in_receive=True) + message = safe_encode(message) + email_server.imap.append("Sent", "\\Seen", imaplib.Time2Internaldate(time.time()), message) + except Exception: + self.log_error("Unable to add to Sent folder") def get_oauth_token(self): if self.auth_method == "OAuth": @@ -766,9 +765,9 @@ def pull(now=False): """Will be called via scheduler, pull emails from all enabled Email accounts.""" from frappe.integrations.doctype.connected_app.connected_app import has_token - if frappe.cache().get_value("workers:no-internet") == True: + if frappe.cache.get_value("workers:no-internet") == True: if test_internet(): - frappe.cache().set_value("workers:no-internet", False) + frappe.cache.set_value("workers:no-internet", False) return doctype = frappe.qb.DocType("Email Account") diff --git a/frappe/email/doctype/email_domain/email_domain.json b/frappe/email/doctype/email_domain/email_domain.json index c162060436..5cb4c19940 100644 --- a/frappe/email/doctype/email_domain/email_domain.json +++ b/frappe/email/doctype/email_domain/email_domain.json @@ -107,6 +107,7 @@ }, { "default": "0", + "depends_on": "eval:doc.use_imap", "fieldname": "append_emails_to_sent_folder", "fieldtype": "Check", "label": "Append Emails to Sent Folder" @@ -133,7 +134,7 @@ "link_fieldname": "domain" } ], - "modified": "2022-08-19 12:55:06.434541", + "modified": "2023-06-05 12:55:06.434541", "modified_by": "Administrator", "module": "Email", "name": "Email Domain", diff --git a/frappe/email/doctype/email_queue/email_queue.js b/frappe/email/doctype/email_queue/email_queue.js index 2ac4b6f7fe..b9a24342ba 100644 --- a/frappe/email/doctype/email_queue/email_queue.js +++ b/frappe/email/doctype/email_queue/email_queue.js @@ -3,7 +3,7 @@ frappe.ui.form.on("Email Queue", { refresh: function (frm) { - if (["Not Sent", "Partially Sent"].indexOf(frm.doc.status) != -1) { + if (["Not Sent", "Partially Sent"].includes(frm.doc.status)) { let button = frm.add_custom_button("Send Now", function () { frappe.call({ method: "frappe.email.doctype.email_queue.email_queue.send_now", @@ -16,20 +16,16 @@ frappe.ui.form.on("Email Queue", { }, }); }); - } - - if (["Error", "Partially Errored"].indexOf(frm.doc.status) != -1) { - let button = frm.add_custom_button("Retry Sending", function () { + } else if (frm.doc.status == "Error") { + frm.add_custom_button("Retry Sending", function () { frm.call({ method: "retry_sending", + doc: frm.doc, args: { name: frm.doc.name, }, - btn: button, - callback: function (r) { - if (!r.exc) { - frm.set_value("status", "Not Sent"); - } + callback: function () { + frm.reload_doc(); }, }); }); diff --git a/frappe/email/doctype/email_queue/email_queue.json b/frappe/email/doctype/email_queue/email_queue.json index ac8d656678..ea1389d8e9 100644 --- a/frappe/email/doctype/email_queue/email_queue.json +++ b/frappe/email/doctype/email_queue/email_queue.json @@ -55,12 +55,14 @@ "default": "Not Sent", "fieldname": "status", "fieldtype": "Select", + "hidden": 1, "in_list_view": 1, "in_standard_filter": 1, "label": "Status", - "options": "\nNot Sent\nSending\nSent\nError\nExpired" + "options": "Not Sent\nSending\nSent\nPartially Sent\nError" }, { + "depends_on": "eval:doc.error", "fieldname": "error", "fieldtype": "Code", "label": "Error" @@ -152,7 +154,7 @@ "idx": 1, "in_create": 1, "links": [], - "modified": "2023-03-16 12:15:17.850292", + "modified": "2023-06-09 14:31:52.789186", "modified_by": "Administrator", "module": "Email", "name": "Email Queue", diff --git a/frappe/email/doctype/email_queue/email_queue.py b/frappe/email/doctype/email_queue/email_queue.py index d254c87a0a..895d8fbe01 100644 --- a/frappe/email/doctype/email_queue/email_queue.py +++ b/frappe/email/doctype/email_queue/email_queue.py @@ -3,14 +3,11 @@ import json import quopri -import smtplib import traceback from contextlib import suppress from email.parser import Parser from email.policy import SMTPUTF8 -from rq.timeouts import JobTimeoutException - import frappe from frappe import _, safe_encode, task from frappe.core.utils import html2text @@ -28,6 +25,7 @@ from frappe.utils import ( get_hook_method, get_string_between, get_url, + now, nowdate, sbool, split_emails, @@ -123,31 +121,33 @@ class EmailQueue(Document): return True - def send(self, is_background_task: bool = False, smtp_server_instance: SMTPServer = None): + def send(self, smtp_server_instance: SMTPServer = None): """Send emails to recipients.""" if not self.can_send_now(): return - with SendMailContext(self, is_background_task, smtp_server_instance) as ctx: + with SendMailContext(self, smtp_server_instance) as ctx: message = None for recipient in self.recipients: - if not recipient.is_mail_to_be_sent(): + if recipient.is_mail_sent(): continue message = ctx.build_message(recipient.recipient) - method = get_hook_method("override_email_send") - if method: + if method := get_hook_method("override_email_send"): method(self, self.sender, recipient.recipient, message) else: if not frappe.flags.in_test: - ctx.smtp_session.sendmail(from_addr=self.sender, to_addrs=recipient.recipient, msg=message) - ctx.add_to_sent_list(recipient) + ctx.smtp_server.session.sendmail( + from_addr=self.sender, to_addrs=recipient.recipient, msg=message + ) + + ctx.update_recipient_status_to_sent(recipient) if frappe.flags.in_test: frappe.flags.sent_mail = message return - if ctx.email_account_doc.append_emails_to_sent_folder and ctx.sent_to: + if ctx.email_account_doc.append_emails_to_sent_folder: ctx.email_account_doc.append_email_to_sent_folder(message) @staticmethod @@ -175,26 +175,30 @@ class EmailQueue(Document): .where(email_recipient.modified < (Now() - Interval(days=days))) ).run() + @frappe.whitelist() + def retry_sending(self): + if self.status == "Error": + self.status = "Not Sent" + self.save(ignore_permissions=True) + @task(queue="short") -def send_mail(email_queue_name, is_background_task=False, smtp_server_instance: SMTPServer = None): +def send_mail(email_queue_name, smtp_server_instance: SMTPServer = None): """This is equivalent to EmailQueue.send. This provides a way to make sending mail as a background job. """ record = EmailQueue.find(email_queue_name) - record.send(is_background_task=is_background_task, smtp_server_instance=smtp_server_instance) + record.send(smtp_server_instance=smtp_server_instance) class SendMailContext: def __init__( self, queue_doc: Document, - is_background_task: bool = False, smtp_server_instance: SMTPServer = None, ): self.queue_doc: EmailQueue = queue_doc - self.is_background_task = is_background_task self.email_account_doc = queue_doc.get_email_account() self.smtp_server = smtp_server_instance or self.email_account_doc.get_smtp_server() @@ -203,67 +207,37 @@ class SendMailContext: # Note: smtp session will have to be manually closed self.retain_smtp_session = bool(smtp_server_instance) - self.sent_to = [rec.recipient for rec in self.queue_doc.recipients if rec.is_mail_sent()] + self.sent_to_atleast_one_recipient = any( + rec.recipient for rec in self.queue_doc.recipients if rec.is_mail_sent() + ) def __enter__(self): self.queue_doc.update_status(status="Sending", commit=True) return self def __exit__(self, exc_type, exc_val, exc_tb): - exceptions = [ - smtplib.SMTPServerDisconnected, - smtplib.SMTPAuthenticationError, - smtplib.SMTPConnectError, - smtplib.SMTPHeloError, - JobTimeoutException, - ] - if not self.retain_smtp_session: self.smtp_server.quit() - self.log_exception(exc_type, exc_val, exc_tb) - - if exc_type in exceptions: - email_status = "Partially Sent" if self.sent_to else "Not Sent" - self.queue_doc.update_status(status=email_status, commit=True) - elif exc_type: - if self.queue_doc.retry < get_email_retry_limit(): - update_fields = {"status": "Not Sent", "retry": self.queue_doc.retry + 1} - else: - update_fields = {"status": (self.sent_to and "Partially Errored") or "Error"} - self.queue_doc.update_status(**update_fields, commit=True) - else: - email_status = self.is_mail_sent_to_all() and "Sent" - email_status = email_status or (self.sent_to and "Partially Sent") or "Not Sent" - - update_fields = { - "status": email_status, - "email_account": self.email_account_doc.name - if self.email_account_doc.is_exists_in_db() - else None, - } - self.queue_doc.update_status(**update_fields, commit=True) - - def log_exception(self, exc_type, exc_val, exc_tb): if exc_type: - traceback_string = "".join(traceback.format_tb(exc_tb)) - traceback_string += f"\n Queue Name: {self.queue_doc.name}" + update_fields = {"error": "".join(traceback.format_tb(exc_tb))} + if self.queue_doc.retry < get_email_retry_limit(): + update_fields.update( + { + "status": "Partially Sent" if self.sent_to_atleast_one_recipient else "Not Sent", + "retry": self.queue_doc.retry + 1, + } + ) + else: + update_fields.update({"status": "Error"}) + else: + update_fields = {"status": "Sent"} - self.queue_doc.log_error("Email sending failed", traceback_string) + self.queue_doc.update_status(**update_fields, commit=True) - @property - def smtp_session(self): - if frappe.flags.in_test: - return - return self.smtp_server.session - - def add_to_sent_list(self, recipient): - # Update recipient status + def update_recipient_status_to_sent(self, recipient): + self.sent_to_atleast_one_recipient = True recipient.update_db(status="Sent", commit=True) - self.sent_to.append(recipient.recipient) - - def is_mail_sent_to_all(self): - return sorted(self.sent_to) == sorted(rec.recipient for rec in self.queue_doc.recipients) def get_message_object(self, message): return Parser(policy=SMTPUTF8).parsestr(message) @@ -375,16 +349,26 @@ class SendMailContext: @frappe.whitelist() -def retry_sending(name): - doc = frappe.get_doc("Email Queue", name) - doc.check_permission() +def bulk_retry(queues): + frappe.only_for("System Manager") - if doc and (doc.status == "Error" or doc.status == "Partially Errored"): - doc.status = "Not Sent" - for d in doc.recipients: - if d.status != "Sent": - d.status = "Not Sent" - doc.save(ignore_permissions=True) + if isinstance(queues, str): + queues = json.loads(queues) + + if not queues: + return + + frappe.msgprint( + _("Updating Email Queue Statuses. The emails will be picked up in the next scheduled run."), + _("Processing..."), + ) + + email_queue = frappe.qb.DocType("Email Queue") + frappe.qb.update(email_queue).set(email_queue.status, "Not Sent").set( + email_queue.modified, now() + ).set(email_queue.modified_by, frappe.session.user).where( + email_queue.name.isin(queues) & email_queue.status == "Error" + ).run() @frappe.whitelist() diff --git a/frappe/email/doctype/email_queue/email_queue_list.js b/frappe/email/doctype/email_queue/email_queue_list.js index b00503b6f8..ea6869e265 100644 --- a/frappe/email/doctype/email_queue/email_queue_list.js +++ b/frappe/email/doctype/email_queue/email_queue_list.js @@ -9,7 +9,10 @@ frappe.listview_settings["Email Queue"] = { }; return [__(doc.status), colour[doc.status], "status,=," + doc.status]; }, - refresh: show_toggle_sending_button, + refresh: function (listview) { + show_toggle_sending_button(listview); + add_bulk_retry_button_to_actions(listview); + }, onload: function (list_view) { frappe.require("logtypes.bundle.js", () => { frappe.utils.logtypes.show_log_retention_message(list_view.doctype); @@ -39,3 +42,21 @@ function show_toggle_sending_button(list_view) { show_toggle_sending_button(list_view); }); } + +function add_bulk_retry_button_to_actions(list_view) { + if (!has_common(frappe.user_roles, ["Administrator", "System Manager"])) return; + + list_view.page.add_actions_menu_item(__("Retry Sending"), () => { + frappe.call({ + method: "frappe.email.doctype.email_queue.email_queue.bulk_retry", + args: { + queues: list_view.get_checked_items(true), + }, + callback: (r) => { + if (!r.exc) { + list_view.refresh(); + } + }, + }); + }); +} diff --git a/frappe/email/doctype/notification/notification.py b/frappe/email/doctype/notification/notification.py index aee68aa4e5..eb0868a91e 100644 --- a/frappe/email/doctype/notification/notification.py +++ b/frappe/email/doctype/notification/notification.py @@ -42,10 +42,10 @@ class Notification(Document): self.validate_forbidden_types() self.validate_condition() self.validate_standard() - frappe.cache().hdel("notifications", self.document_type) + frappe.cache.hdel("notifications", self.document_type) def on_update(self): - frappe.cache().hdel("notifications", self.document_type) + frappe.cache.hdel("notifications", self.document_type) path = export_module_json(self, self.is_standard, self.module) if path: # js @@ -378,7 +378,7 @@ def get_context(context): self.message = frappe.utils.md_to_html(self.message) def on_trash(self): - frappe.cache().hdel("notifications", self.document_type) + frappe.cache.hdel("notifications", self.document_type) @frappe.whitelist() diff --git a/frappe/email/queue.py b/frappe/email/queue.py index 7d4b92baf1..0df88ebd5c 100755 --- a/frappe/email/queue.py +++ b/frappe/email/queue.py @@ -154,7 +154,6 @@ def flush(from_test=False): frappe.enqueue( method=send_mail, email_queue_name=row.name, - is_background_task=not from_test, now=from_test, job_name=job_name, queue="short", @@ -180,19 +179,3 @@ def get_queue(): {"now": now_datetime()}, as_dict=True, ) - - -def set_expiry_for_email_queue(): - """Mark emails as expire that has not sent for 7 days. - Called daily via scheduler. - """ - - frappe.db.sql( - """ - UPDATE `tabEmail Queue` - SET `status`='Expired' - WHERE `modified` < (NOW() - INTERVAL '7' DAY) - AND `status`='Not Sent' - AND (`send_after` IS NULL OR `send_after` < %(now)s)""", - {"now": now_datetime()}, - ) diff --git a/frappe/email/smtp.py b/frappe/email/smtp.py index 3b22bc4ce4..7b15440ccf 100644 --- a/frappe/email/smtp.py +++ b/frappe/email/smtp.py @@ -13,36 +13,6 @@ class InvalidEmailCredentials(frappe.ValidationError): pass -def send(email, append_to=None, retry=1): - """Deprecated: Send the message or add it to Outbox Email""" - - def _send(retry): - from frappe.email.doctype.email_account.email_account import EmailAccount - - try: - email_account = EmailAccount.find_outgoing(match_by_doctype=append_to) - smtpserver = email_account.get_smtp_server() - - # validate is called in as_string - email_body = email.as_string() - - smtpserver.sess.sendmail(email.sender, email.recipients + (email.cc or []), email_body) - except smtplib.SMTPSenderRefused: - frappe.throw(_("Invalid login or password"), title="Email Failed") - raise - except smtplib.SMTPRecipientsRefused: - frappe.msgprint(_("Invalid recipient address"), title="Email Failed") - raise - except (smtplib.SMTPServerDisconnected, smtplib.SMTPAuthenticationError): - if not retry: - raise - else: - retry = retry - 1 - _send(retry) - - _send(retry) - - class SMTPServer: def __init__( self, diff --git a/frappe/frappeclient.py b/frappe/frappeclient.py index 3f7577fac6..7ad016828a 100644 --- a/frappe/frappeclient.py +++ b/frappe/frappeclient.py @@ -4,8 +4,6 @@ FrappeClient is a library that helps you connect with other frappe systems import base64 import json -import requests - import frappe from frappe.utils.data import cstr @@ -37,6 +35,8 @@ class FrappeClient: api_secret=None, frappe_authorization_source=None, ): + import requests + self.headers = { "Accept": "application/json", "content-type": "application/x-www-form-urlencoded", @@ -390,42 +390,13 @@ class FrappeClient: class FrappeOAuth2Client(FrappeClient): def __init__(self, url, access_token, verify=True): + import requests + self.access_token = access_token self.headers = { "Authorization": "Bearer " + access_token, "content-type": "application/x-www-form-urlencoded", } self.verify = verify - self.session = OAuth2Session(self.headers) + self.session = requests.session() self.url = url - - def get_request(self, params): - res = requests.get( - self.url, params=self.preprocess(params), headers=self.headers, verify=self.verify - ) - res = self.post_process(res) - return res - - def post_request(self, data): - res = requests.post( - self.url, data=self.preprocess(data), headers=self.headers, verify=self.verify - ) - res = self.post_process(res) - return res - - -class OAuth2Session: - def __init__(self, headers): - self.headers = headers - - def get(self, url, params, verify): - res = requests.get(url, params=params, headers=self.headers, verify=verify) - return res - - def post(self, url, data, verify): - res = requests.post(url, data=data, headers=self.headers, verify=verify) - return res - - def put(self, url, data, verify): - res = requests.put(url, data=data, headers=self.headers, verify=verify) - return res diff --git a/frappe/hooks.py b/frappe/hooks.py index edf572b642..ed2b25bc1b 100644 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -134,7 +134,6 @@ jinja = { "filters": [ "frappe.utils.data.global_date_format", "frappe.utils.markdown", - "frappe.website.utils.get_shade", "frappe.website.utils.abs_url", ], } @@ -223,7 +222,6 @@ scheduler_events = { "frappe.website.doctype.personal_data_deletion_request.personal_data_deletion_request.process_data_deletion_request", ], "daily": [ - "frappe.email.queue.set_expiry_for_email_queue", "frappe.desk.notifications.clear_notifications", "frappe.desk.doctype.event.event.send_event_digest", "frappe.sessions.clear_expired_sessions", diff --git a/frappe/installer.py b/frappe/installer.py index 9c2807d7cd..4f02e207bd 100644 --- a/frappe/installer.py +++ b/frappe/installer.py @@ -287,6 +287,9 @@ def install_app(name, verbose=False, set_as_patched=True, force=False): if out is False: return + for fn in frappe.get_hooks("before_app_install"): + frappe.get_attr(fn)(name) + if name != "frappe": add_module_defs(name, ignore_if_duplicate=force) @@ -302,6 +305,9 @@ def install_app(name, verbose=False, set_as_patched=True, force=False): for after_install in app_hooks.after_install or []: frappe.get_attr(after_install)() + for fn in frappe.get_hooks("after_app_install"): + frappe.get_attr(fn)(name) + sync_jobs() sync_fixtures(name) sync_customizations(name) @@ -369,6 +375,9 @@ def remove_app(app_name, dry_run=False, yes=False, no_backup=False, force=False) for before_uninstall in app_hooks.before_uninstall or []: frappe.get_attr(before_uninstall)() + for fn in frappe.get_hooks("before_app_uninstall"): + frappe.get_attr(fn)(app_name) + modules = frappe.get_all("Module Def", filters={"app_name": app_name}, pluck="name") drop_doctypes = _delete_modules(modules, dry_run=dry_run) @@ -382,6 +391,9 @@ def remove_app(app_name, dry_run=False, yes=False, no_backup=False, force=False) for after_uninstall in app_hooks.after_uninstall or []: frappe.get_attr(after_uninstall)() + for fn in frappe.get_hooks("after_app_uninstall"): + frappe.get_attr(fn)(app_name) + click.secho(f"Uninstalled App {app_name} from Site {site}", fg="green") frappe.flags.in_uninstall = False diff --git a/frappe/integrations/doctype/google_calendar/google_calendar.py b/frappe/integrations/doctype/google_calendar/google_calendar.py index d1cdd0d9e7..136e39e3a3 100644 --- a/frappe/integrations/doctype/google_calendar/google_calendar.py +++ b/frappe/integrations/doctype/google_calendar/google_calendar.py @@ -119,7 +119,7 @@ def authorize_access(g_calendar, reauthorize=None): ) if not google_calendar.authorization_code or reauthorize: - frappe.cache().hset("google_calendar", "google_calendar", google_calendar.name) + frappe.cache.hset("google_calendar", "google_calendar", google_calendar.name) return get_authentication_url(client_id=google_settings.client_id, redirect_uri=redirect_uri) else: try: @@ -163,7 +163,7 @@ def google_callback(code=None): """ Authorization code is sent to callback as per the API configuration """ - google_calendar = frappe.cache().hget("google_calendar", "google_calendar") + google_calendar = frappe.cache.hget("google_calendar", "google_calendar") frappe.db.set_value("Google Calendar", google_calendar, "authorization_code", code) frappe.db.commit() diff --git a/frappe/integrations/doctype/webhook/__init__.py b/frappe/integrations/doctype/webhook/__init__.py index b9c96190ca..dcad1c8b5c 100644 --- a/frappe/integrations/doctype/webhook/__init__.py +++ b/frappe/integrations/doctype/webhook/__init__.py @@ -20,7 +20,7 @@ def run_webhooks(doc, method): # TODO: remove this hazardous unnecessary cache in flags if frappe.flags.webhooks is None: # load webhooks from cache - webhooks = frappe.cache().get_value("webhooks") + webhooks = frappe.cache.get_value("webhooks") if webhooks is None: # query webhooks webhooks_list = frappe.get_all( @@ -33,7 +33,7 @@ def run_webhooks(doc, method): webhooks = {} for w in webhooks_list: webhooks.setdefault(w.webhook_doctype, []).append(w) - frappe.cache().set_value("webhooks", webhooks) + frappe.cache.set_value("webhooks", webhooks) frappe.flags.webhooks = webhooks diff --git a/frappe/integrations/doctype/webhook/test_webhook.py b/frappe/integrations/doctype/webhook/test_webhook.py index 2edf2fcf5c..d308ec95ab 100644 --- a/frappe/integrations/doctype/webhook/test_webhook.py +++ b/frappe/integrations/doctype/webhook/test_webhook.py @@ -3,6 +3,9 @@ import json from contextlib import contextmanager +import responses +from responses.matchers import json_params_matcher + import frappe from frappe.integrations.doctype.webhook.webhook import ( enqueue_webhook, @@ -94,15 +97,21 @@ class TestWebhook(FrappeTestCase): self.test_user.email = "user1@integration.webhooks.test.com" self.test_user.first_name = "user1" + self.responses = responses.RequestsMock() + self.responses.start() + def tearDown(self) -> None: self.user.delete() self.test_user.delete() + + self.responses.stop() + self.responses.reset() super().tearDown() def test_webhook_trigger_with_enabled_webhooks(self): """Test webhook trigger for enabled webhooks""" - frappe.cache().delete_value("webhooks") + frappe.cache.delete_value("webhooks") frappe.flags.webhooks = None # Insert the user to db @@ -172,6 +181,13 @@ class TestWebhook(FrappeTestCase): self.assertEqual(data, {"name": self.user.name}) def test_webhook_req_log_creation(self): + self.responses.add( + responses.POST, + "https://httpbin.org/post", + status=200, + json={}, + ) + if not frappe.db.get_value("User", "user2@integration.webhooks.test.com"): user = frappe.get_doc( {"doctype": "User", "email": "user2@integration.webhooks.test.com", "first_name": "user2"} @@ -185,6 +201,7 @@ class TestWebhook(FrappeTestCase): self.assertTrue(frappe.get_all("Webhook Request Log", pluck="name")) def test_webhook_with_array_body(self): + """Check if array request body are supported.""" wh_config = { "doctype": "Webhook", @@ -194,7 +211,7 @@ class TestWebhook(FrappeTestCase): "request_url": "https://httpbin.org/post", "request_method": "POST", "request_structure": "JSON", - "webhook_json": '[\r\n{% for n in range(3) %}\r\n {\r\n "title": "{{ doc.title }}",\r\n "n": {{ n }}\r\n }\r\n {%- if not loop.last -%}\r\n , \r\n {%endif%}\r\n{%endfor%}\r\n]', + "webhook_json": '[\r\n{% for n in range(3) %}\r\n {\r\n "title": "{{ doc.title }}" }\r\n {%- if not loop.last -%}\r\n , \r\n {%endif%}\r\n{%endfor%}\r\n]', "meets_condition": "Yes", "webhook_headers": [ { @@ -204,13 +221,22 @@ class TestWebhook(FrappeTestCase): ], } - with get_test_webhook(wh_config) as wh: - doc = frappe.new_doc("Note") - doc.title = "Test Webhook Note" + doc = frappe.new_doc("Note") + doc.title = "Test Webhook Note" + expected_req = [{"title": doc.title} for _ in range(3)] + self.responses.add( + responses.POST, + "https://httpbin.org/post", + status=200, + json=expected_req, + match=[json_params_matcher(expected_req)], + ) + + with get_test_webhook(wh_config) as wh: enqueue_webhook(doc, wh) log = frappe.get_last_doc("Webhook Request Log") - self.assertEqual(len(json.loads(log.response)["json"]), 3) + self.assertEqual(len(json.loads(log.response)), 3) def test_webhook_with_dynamic_url_enabled(self): wh_config = { @@ -232,12 +258,16 @@ class TestWebhook(FrappeTestCase): ], } + self.responses.add( + responses.POST, + "https://httpbin.org/anything/Note", + status=200, + ) + with get_test_webhook(wh_config) as wh: doc = frappe.new_doc("Note") doc.title = "Test Webhook Note" enqueue_webhook(doc, wh) - log = frappe.get_last_doc("Webhook Request Log") - self.assertEqual(json.loads(log.response)["url"], "https://httpbin.org/anything/Note") def test_webhook_with_dynamic_url_disabled(self): wh_config = { @@ -259,11 +289,13 @@ class TestWebhook(FrappeTestCase): ], } + self.responses.add( + responses.POST, + "https://httpbin.org/anything/{{doc.doctype}}", + status=200, + ) + with get_test_webhook(wh_config) as wh: doc = frappe.new_doc("Note") doc.title = "Test Webhook Note" enqueue_webhook(doc, wh) - log = frappe.get_last_doc("Webhook Request Log") - self.assertEqual( - json.loads(log.response)["url"], "https://httpbin.org/anything/{{doc.doctype}}" - ) diff --git a/frappe/integrations/doctype/webhook/webhook.json b/frappe/integrations/doctype/webhook/webhook.json index 404e0be944..1728da97d7 100644 --- a/frappe/integrations/doctype/webhook/webhook.json +++ b/frappe/integrations/doctype/webhook/webhook.json @@ -17,6 +17,7 @@ "html_condition", "sb_webhook", "request_url", + "timeout", "is_dynamic_url", "cb_webhook", "request_method", @@ -204,6 +205,14 @@ "fieldname": "is_dynamic_url", "fieldtype": "Check", "label": "Is Dynamic URL?" + }, + { + "default": "5", + "description": "The number of seconds until the request expires", + "fieldname": "timeout", + "fieldtype": "Int", + "label": "Request Timeout", + "reqd": 1 } ], "links": [ @@ -212,7 +221,7 @@ "link_fieldname": "webhook" } ], - "modified": "2023-06-02 17:25:12.598232", + "modified": "2023-06-16 10:21:00.971833", "modified_by": "Administrator", "module": "Integrations", "name": "Webhook", diff --git a/frappe/integrations/doctype/webhook/webhook.py b/frappe/integrations/doctype/webhook/webhook.py index 1b56a1b129..6fa24bfb67 100644 --- a/frappe/integrations/doctype/webhook/webhook.py +++ b/frappe/integrations/doctype/webhook/webhook.py @@ -29,7 +29,7 @@ class Webhook(Document): self.preview_document = None def on_update(self): - frappe.cache().delete_value("webhooks") + frappe.cache.delete_value("webhooks") def validate_docevent(self): if self.webhook_doctype: @@ -129,7 +129,7 @@ def enqueue_webhook(doc, webhook) -> None: url=request_url, data=json.dumps(data, default=str), headers=headers, - timeout=5, + timeout=webhook.timeout or 5, ) r.raise_for_status() frappe.logger().debug({"webhook_success": r.text}) diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index 63188e749d..609bfa4b9e 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -646,7 +646,10 @@ class BaseDocument: def update_modified(self): """Update modified timestamp""" self.set("modified", now()) - frappe.db.set_value(self.doctype, self.name, "modified", self.modified, update_modified=False) + if getattr(self.meta, "issingle", False): + frappe.db.set_single_value(self.doctype, "modified", self.modified, update_modified=False) + else: + frappe.db.set_value(self.doctype, self.name, "modified", self.modified, update_modified=False) def _fix_numeric_types(self): for df in self.meta.get("fields"): diff --git a/frappe/model/document.py b/frappe/model/document.py index f944b28a49..e2a55065bb 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -204,12 +204,11 @@ class Document(BaseDocument): if not self.has_permission(permtype): self.raise_no_permission_to(permlevel or permtype) - def has_permission(self, permtype="read", verbose=False) -> bool: + def has_permission(self, permtype="read") -> bool: """ Call `frappe.permissions.has_permission` if `ignore_permissions` flag isn't truthy :param permtype: `read`, `write`, `submit`, `cancel`, `delete`, etc. - :param verbose: DEPRECATED, will be removed in a future release. """ if self.flags.ignore_permissions: @@ -959,9 +958,7 @@ class Document(BaseDocument): filters={"enabled": 1, "document_type": self.doctype}, ) - self.flags.notifications = frappe.cache().hget( - "notifications", self.doctype, _get_notifications - ) + self.flags.notifications = frappe.cache.hget("notifications", self.doctype, _get_notifications) if not self.flags.notifications: return @@ -1126,7 +1123,7 @@ class Document(BaseDocument): def reset_seen(self): """Clear _seen property and set current user as seen""" - if getattr(self.meta, "track_seen", False): + if getattr(self.meta, "track_seen", False) and not getattr(self.meta, "issingle", False): frappe.db.set_value( self.doctype, self.name, "_seen", json.dumps([frappe.session.user]), update_modified=False ) @@ -1185,15 +1182,25 @@ class Document(BaseDocument): if self.name is None: return - frappe.db.set_value( - self.doctype, - self.name, - fieldname, - value, - self.modified, - self.modified_by, - update_modified=update_modified, - ) + if self.meta.issingle: + frappe.db.set_single_value( + self.doctype, + fieldname, + value, + modified=self.modified, + modified_by=self.modified_by, + update_modified=update_modified, + ) + else: + frappe.db.set_value( + self.doctype, + self.name, + fieldname, + value, + self.modified, + self.modified_by, + update_modified=update_modified, + ) self.run_method("on_change") @@ -1378,7 +1385,7 @@ class Document(BaseDocument): if not user: user = frappe.session.user - if self.meta.track_seen and not frappe.flags.read_only: + if self.meta.track_seen and not frappe.flags.read_only and not self.meta.issingle: _seen = self.get("_seen") or [] _seen = frappe.parse_json(_seen) diff --git a/frappe/model/meta.py b/frappe/model/meta.py index 32c1326170..83c21f8502 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -56,14 +56,12 @@ DEFAULT_FIELD_LABELS = { def get_meta(doctype, cached=True) -> "Meta": - if not cached: - return Meta(doctype) - - if meta := frappe.cache().hget("doctype_meta", doctype): + cached = cached and isinstance(doctype, str) + if cached and (meta := frappe.cache.hget("doctype_meta", doctype)): return meta meta = Meta(doctype) - frappe.cache().hset("doctype_meta", doctype, meta) + frappe.cache.hset("doctype_meta", meta.name, meta) return meta @@ -134,13 +132,10 @@ class Meta(Document): self.init_field_caches() return - has_custom_fields = self.add_custom_fields() + self.add_custom_fields() self.apply_property_setters() self.init_field_caches() - - if has_custom_fields: - self.sort_fields() - + self.sort_fields() self.get_valid_columns() self.set_custom_permissions() self.add_custom_links_and_actions() @@ -361,7 +356,6 @@ class Meta(Document): return self.extend("fields", custom_fields) - return True def apply_property_setters(self): """ @@ -372,11 +366,11 @@ class Meta(Document): if not frappe.db.table_exists("Property Setter"): return - property_setters = frappe.db.sql( - """select * from `tabProperty Setter` where - doc_type=%s""", - (self.name,), - as_dict=1, + property_setters = frappe.db.get_values( + "Property Setter", + filters={"doc_type": self.name}, + fieldname="*", + as_dict=True, ) if not property_setters: @@ -452,14 +446,56 @@ class Meta(Document): self._table_fields = self.get("fields", {"fieldtype": ["in", table_fields]}) def sort_fields(self): - """Sort custom fields on the basis of insert_after""" + """ + Sort fields on the basis of following rules (priority descending): + - `field_order` property setter + - `insert_after` computed based on default order for standard fields + - `insert_after` property for custom fields + """ - field_order = [] + if field_order := getattr(self, "field_order", []): + field_order = [fieldname for fieldname in json.loads(field_order) if fieldname in self._fields] + + # all fields match, best case scenario + if len(field_order) == len(self.fields): + self._update_fields_based_on_order(field_order) + return + + # if the first few standard fields are not in the field order, prepare to prepend them + if self.fields[0].fieldname not in field_order: + fields_to_prepend = [] + standard_field_found = False + + for fieldname, field in self._fields.items(): + if getattr(field, "is_custom_field", False): + # all custom fields from here on + break + + if fieldname in field_order: + standard_field_found = True + break + + fields_to_prepend.append(fieldname) + + if standard_field_found: + field_order = fields_to_prepend + field_order + else: + # worst case scenario, invalidate field_order + field_order = fields_to_prepend + + existing_fields = set(field_order) if field_order else False insert_after_map = {} - for field in self.fields: + for index, field in enumerate(self.fields): + if existing_fields and field.fieldname in existing_fields: + continue + if not getattr(field, "is_custom_field", False): - field_order.append(field.fieldname) + if existing_fields: + # compute insert_after from previous field + insert_after_map.setdefault(self.fields[index - 1].fieldname, []).append(field.fieldname) + else: + field_order.append(field.fieldname) elif insert_after := getattr(field, "insert_after", None): insert_after_map.setdefault(insert_after, []).append(field.fieldname) @@ -471,6 +507,9 @@ class Meta(Document): if insert_after_map: _update_field_order_based_on_insert_after(field_order, insert_after_map) + self._update_fields_based_on_order(field_order) + + def _update_fields_based_on_order(self, field_order): sorted_fields = [] for idx, fieldname in enumerate(field_order, 1): @@ -814,7 +853,7 @@ def trim_tables(doctype=None, dry_run=False, quiet=False): def trim_table(doctype, dry_run=True): - frappe.cache().hdel("table_columns", f"tab{doctype}") + frappe.cache.hdel("table_columns", f"tab{doctype}") ignore_fields = default_fields + optional_fields + child_table_fields columns = frappe.db.get_table_columns(doctype) fields = frappe.get_meta(doctype, cached=False).get_fieldnames_with_value() diff --git a/frappe/model/naming.py b/frappe/model/naming.py index 73b5930563..0b76d18cff 100644 --- a/frappe/model/naming.py +++ b/frappe/model/naming.py @@ -151,7 +151,8 @@ def set_new_name(doc): if getattr(doc, "amended_from", None): _set_amended_name(doc) - return + if doc.name: + return elif getattr(doc.meta, "issingle", False): doc.name = doc.doctype @@ -506,6 +507,17 @@ def append_number_if_name_exists(doctype, value, fieldname="name", separator="-" def _set_amended_name(doc): + amend_naming_rule = frappe.db.get_value( + "Amended Document Naming Settings", {"document_type": doc.doctype}, "action", cache=True + ) + if not amend_naming_rule: + amend_naming_rule = frappe.db.get_single_value( + "Document Naming Settings", "default_amend_naming", cache=True + ) + + if amend_naming_rule == "Default Naming": + return + am_id = 1 am_prefix = doc.amended_from if frappe.db.get_value(doc.doctype, doc.amended_from, "amended_from"): diff --git a/frappe/model/rename_doc.py b/frappe/model/rename_doc.py index 3908365291..e8f5626af4 100644 --- a/frappe/model/rename_doc.py +++ b/frappe/model/rename_doc.py @@ -53,6 +53,7 @@ def update_document_title( # handle bad API usages merge = sbool(merge) enqueue = sbool(enqueue) + action_enqueued = enqueue and not is_scheduler_inactive() doc = frappe.get_doc(doctype, docname) doc.check_permission(permtype="write") @@ -65,7 +66,7 @@ def update_document_title( name_updated = updated_name and (updated_name != doc.name) if name_updated: - if enqueue and not is_scheduler_inactive(): + if action_enqueued: current_name = doc.name # before_name hook may have DocType specific validations or transformations @@ -90,18 +91,27 @@ def update_document_title( doc.rename(updated_name, merge=merge) if title_updated: - try: - setattr(doc, title_field, updated_title) - doc.save() - frappe.msgprint(_("Saved"), alert=True, indicator="green") - except Exception as e: - if frappe.db.is_duplicate_entry(e): - frappe.throw( - _("{0} {1} already exists").format(doctype, frappe.bold(docname)), - title=_("Duplicate Name"), - exc=frappe.DuplicateEntryError, - ) - raise + if action_enqueued and name_updated: + frappe.enqueue( + "frappe.client.set_value", + doctype=doc.doctype, + name=updated_name, + fieldname=title_field, + value=updated_title, + ) + else: + try: + setattr(doc, title_field, updated_title) + doc.save() + frappe.msgprint(_("Saved"), alert=True, indicator="green") + except Exception as e: + if frappe.db.is_duplicate_entry(e): + frappe.throw( + _("{0} {1} already exists").format(doctype, frappe.bold(docname)), + title=_("Duplicate Name"), + exc=frappe.DuplicateEntryError, + ) + raise return doc.name @@ -685,39 +695,3 @@ def bulk_rename( if not via_console: return rename_log - - -def update_linked_doctypes( - doctype: str, docname: str, linked_to: str, value: str, ignore_doctypes: list | None = None -) -> None: - from frappe.model.utils.rename_doc import update_linked_doctypes - - show_deprecation_warning("update_linked_doctypes") - - return update_linked_doctypes( - doctype=doctype, - docname=docname, - linked_to=linked_to, - value=value, - ignore_doctypes=ignore_doctypes, - ) - - -def get_fetch_fields( - doctype: str, linked_to: str, ignore_doctypes: list | None = None -) -> list[dict]: - from frappe.model.utils.rename_doc import get_fetch_fields - - show_deprecation_warning("get_fetch_fields") - - return get_fetch_fields(doctype=doctype, linked_to=linked_to, ignore_doctypes=ignore_doctypes) - - -def show_deprecation_warning(funct: str) -> None: - from click import secho - - message = ( - f"Function frappe.model.rename_doc.{funct} has been deprecated and " - "moved to the frappe.model.utils.rename_doc" - ) - secho(message, fg="yellow") diff --git a/frappe/model/utils/__init__.py b/frappe/model/utils/__init__.py index 2935872fc7..f8f5b21de4 100644 --- a/frappe/model/utils/__init__.py +++ b/frappe/model/utils/__init__.py @@ -133,3 +133,13 @@ def is_virtual_doctype(doctype: str): if frappe.db.has_column("DocType", "is_virtual"): return frappe.db.get_value("DocType", doctype, "is_virtual") return False + + +@site_cache() +def is_single_doctype(doctype: str) -> bool: + from frappe.model.base_document import DOCTYPES_FOR_DOCTYPE + + if doctype in DOCTYPES_FOR_DOCTYPE: + return False + + return frappe.db.get_value("DocType", doctype, "issingle") diff --git a/frappe/model/utils/link_count.py b/frappe/model/utils/link_count.py index 49ed0d5a6c..65b5092d46 100644 --- a/frappe/model/utils/link_count.py +++ b/frappe/model/utils/link_count.py @@ -23,7 +23,7 @@ def flush_local_link_count(): if not new_links: return - link_count = frappe.cache().get_value("_link_count") or {} + link_count = frappe.cache.get_value("_link_count") or {} for key, value in new_links.items(): if key in link_count: @@ -31,13 +31,13 @@ def flush_local_link_count(): else: link_count[key] = value - frappe.cache().set_value("_link_count", link_count) + frappe.cache.set_value("_link_count", link_count) new_links.clear() def update_link_count(): """increment link count in the `idx` column for the given document""" - link_count = frappe.cache().get_value("_link_count") + link_count = frappe.cache.get_value("_link_count") if link_count: for (doctype, name), count in link_count.items(): @@ -50,4 +50,4 @@ def update_link_count(): if not frappe.db.is_table_missing(e): # table not found, single raise e # reset the count - frappe.cache().delete_value("_link_count") + frappe.cache.delete_value("_link_count") diff --git a/frappe/model/utils/user_settings.py b/frappe/model/utils/user_settings.py index c12c7e27ba..02bc67b929 100644 --- a/frappe/model/utils/user_settings.py +++ b/frappe/model/utils/user_settings.py @@ -11,7 +11,7 @@ filter_dict = {"doctype": 0, "docfield": 1, "operator": 2, "value": 3} def get_user_settings(doctype, for_update=False): - user_settings = frappe.cache().hget("_user_settings", f"{doctype}::{frappe.session.user}") + user_settings = frappe.cache.hget("_user_settings", f"{doctype}::{frappe.session.user}") if user_settings is None: user_settings = frappe.db.sql( @@ -41,12 +41,12 @@ def update_user_settings(doctype, user_settings, for_update=False): current.update(user_settings) - frappe.cache().hset("_user_settings", f"{doctype}::{frappe.session.user}", json.dumps(current)) + frappe.cache.hset("_user_settings", f"{doctype}::{frappe.session.user}", json.dumps(current)) def sync_user_settings(): """Sync from cache to database (called asynchronously via the browser)""" - for key, data in frappe.cache().hgetall("_user_settings").items(): + for key, data in frappe.cache.hgetall("_user_settings").items(): key = safe_decode(key) doctype, user = key.split("::") # WTF? frappe.db.multisql( @@ -99,4 +99,4 @@ def update_user_settings_data( ) # clear that user settings from the redis cache - frappe.cache().hset("_user_settings", f"{user_setting.doctype}::{user_setting.user}", None) + frappe.cache.hset("_user_settings", f"{user_setting.doctype}::{user_setting.user}", None) diff --git a/frappe/model/workflow.py b/frappe/model/workflow.py index d61d2b3a2b..0e345a6515 100644 --- a/frappe/model/workflow.py +++ b/frappe/model/workflow.py @@ -26,12 +26,12 @@ class WorkflowPermissionError(frappe.ValidationError): def get_workflow_name(doctype): - workflow_name = frappe.cache().hget("workflow", doctype) + workflow_name = frappe.cache.hget("workflow", doctype) if workflow_name is None: workflow_name = frappe.db.get_value( "Workflow", {"document_type": doctype, "is_active": 1}, "name" ) - frappe.cache().hset("workflow", doctype, workflow_name or "") + frappe.cache.hset("workflow", doctype, workflow_name or "") return workflow_name @@ -210,7 +210,7 @@ def validate_workflow(doc): def get_workflow(doctype) -> "Workflow": - return frappe.get_doc("Workflow", get_workflow_name(doctype)) + return frappe.get_cached_doc("Workflow", get_workflow_name(doctype)) def has_approval_access(user, doc, transition): @@ -228,10 +228,10 @@ def send_email_alert(workflow_name): def get_workflow_field_value(workflow_name, field): - value = frappe.cache().hget("workflow_" + workflow_name, field) + value = frappe.cache.hget("workflow_" + workflow_name, field) if value is None: value = frappe.db.get_value("Workflow", workflow_name, field) - frappe.cache().hset("workflow_" + workflow_name, field, value) + frappe.cache.hset("workflow_" + workflow_name, field, value) return value diff --git a/frappe/modules/import_file.py b/frappe/modules/import_file.py index 36e329409a..8c9a209501 100644 --- a/frappe/modules/import_file.py +++ b/frappe/modules/import_file.py @@ -34,6 +34,7 @@ ignore_values = { "Print Style": ["disabled"], "Module Onboarding": ["is_complete"], "Onboarding Step": ["is_complete", "is_skipped"], + "Workspace": ["is_hidden"], } ignore_doctypes = [""] diff --git a/frappe/modules/utils.py b/frappe/modules/utils.py index 57d3e8f7ad..40e3b32690 100644 --- a/frappe/modules/utils.py +++ b/frappe/modules/utils.py @@ -214,7 +214,7 @@ def export_doc(doctype, name, module=None): def get_doctype_module(doctype: str) -> str: """Returns **Module Def** name of given doctype.""" - doctype_module_map = frappe.cache().get_value( + doctype_module_map = frappe.cache.get_value( "doctype_modules", generator=lambda: dict(frappe.qb.from_("DocType").select("name", "module").run()), ) diff --git a/frappe/monitor.py b/frappe/monitor.py index b93ba1d3bb..da2deb859e 100644 --- a/frappe/monitor.py +++ b/frappe/monitor.py @@ -106,22 +106,22 @@ class Monitor: traceback.print_exc() def store(self): - if frappe.cache().llen(MONITOR_REDIS_KEY) > MONITOR_MAX_ENTRIES: - frappe.cache().ltrim(MONITOR_REDIS_KEY, 1, -1) + if frappe.cache.llen(MONITOR_REDIS_KEY) > MONITOR_MAX_ENTRIES: + frappe.cache.ltrim(MONITOR_REDIS_KEY, 1, -1) serialized = json.dumps(self.data, sort_keys=True, default=str, separators=(",", ":")) - frappe.cache().rpush(MONITOR_REDIS_KEY, serialized) + frappe.cache.rpush(MONITOR_REDIS_KEY, serialized) def flush(): try: # Fetch all the logs without removing from cache - logs = frappe.cache().lrange(MONITOR_REDIS_KEY, 0, -1) + logs = frappe.cache.lrange(MONITOR_REDIS_KEY, 0, -1) if logs: logs = list(map(frappe.safe_decode, logs)) with open(log_file(), "a", os.O_NONBLOCK) as f: f.write("\n".join(logs)) f.write("\n") # Remove fetched entries from cache - frappe.cache().ltrim(MONITOR_REDIS_KEY, len(logs) - 1, -1) + frappe.cache.ltrim(MONITOR_REDIS_KEY, len(logs) - 1, -1) except Exception: traceback.print_exc() diff --git a/frappe/patches.txt b/frappe/patches.txt index d3d5e3ee15..c26b1a74d7 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -60,7 +60,6 @@ frappe.patches.v10_0.refactor_social_login_keys frappe.patches.v10_0.enable_chat_by_default_within_system_settings frappe.patches.v10_0.remove_custom_field_for_disabled_domain execute:frappe.delete_doc("Page", "chat") -frappe.patches.v10_0.migrate_passwords_passlib frappe.patches.v11_0.rename_standard_reply_to_email_template execute:frappe.delete_doc_if_exists('Page', 'user-permissions') frappe.patches.v10_0.set_no_copy_to_workflow_state @@ -183,7 +182,6 @@ frappe.patches.v13_0.reset_corrupt_defaults frappe.patches.v13_0.remove_share_for_std_users execute:frappe.reload_doc('custom', 'doctype', 'custom_field') frappe.email.doctype.email_queue.patches.drop_search_index_on_message_id -frappe.patches.v14_0.update_workspace2 # 20.09.2021 frappe.patches.v14_0.save_ratings_in_fraction #23-12-2021 frappe.patches.v14_0.transform_todo_schema frappe.patches.v14_0.remove_post_and_post_comment @@ -201,6 +199,7 @@ execute:frappe.reload_doc("desk", "doctype", "Form Tour") [post_model_sync] execute:frappe.get_doc('Role', 'Guest').save() # remove desk access frappe.core.doctype.role.patches.v13_set_default_desk_properties +frappe.patches.v14_0.update_workspace2 # 06.06.2023 frappe.patches.v14_0.drop_data_import_legacy frappe.patches.v14_0.copy_mail_data #08.03.21 frappe.patches.v14_0.update_github_endpoints #08-11-2021 @@ -226,4 +225,5 @@ frappe.patches.v15_0.remove_prepared_report_settings_from_system_settings frappe.patches.v14_0.remove_manage_subscriptions_from_navbar frappe.patches.v15_0.remove_background_jobs_from_dropdown frappe.desk.doctype.form_tour.patches.introduce_ui_tours -execute:frappe.delete_doc_if_exists("Workspace", "Customization") \ No newline at end of file +execute:frappe.delete_doc_if_exists("Workspace", "Customization") +execute:frappe.db.set_single_value("Document Naming Settings", "default_amend_naming", "Amend Counter") diff --git a/frappe/patches/v10_0/migrate_passwords_passlib.py b/frappe/patches/v10_0/migrate_passwords_passlib.py deleted file mode 100644 index b18581ee3e..0000000000 --- a/frappe/patches/v10_0/migrate_passwords_passlib.py +++ /dev/null @@ -1,23 +0,0 @@ -import frappe -from frappe.utils.password import LegacyPassword - - -def execute(): - all_auths = frappe.db.sql( - """SELECT `name`, `password`, `salt` FROM `__Auth` - WHERE doctype='User' AND `fieldname`='password'""", - as_dict=True, - ) - - for auth in all_auths: - if auth.salt and auth.salt != "": - pwd = LegacyPassword.hash(auth.password, salt=auth.salt.encode("UTF-8")) - frappe.db.sql( - """UPDATE `__Auth` SET `password`=%(pwd)s, `salt`=NULL - WHERE `doctype`='User' AND `fieldname`='password' AND `name`=%(user)s""", - {"pwd": pwd, "user": auth.name}, - ) - - frappe.reload_doctype("User") - - frappe.db.sql_ddl("""ALTER TABLE `__Auth` DROP COLUMN `salt`""") diff --git a/frappe/patches/v14_0/update_workspace2.py b/frappe/patches/v14_0/update_workspace2.py index a6c9db503f..d7f03b3184 100644 --- a/frappe/patches/v14_0/update_workspace2.py +++ b/frappe/patches/v14_0/update_workspace2.py @@ -5,28 +5,15 @@ from frappe import _ def execute(): - frappe.reload_doc("desk", "doctype", "workspace", force=True) - - child_tables = frappe.get_all( - "DocField", - pluck="options", - filters={"fieldtype": ["in", frappe.model.table_fields], "parent": "Workspace"}, - ) - - for child_table in child_tables: - if child_table != "Has Role": - frappe.reload_doc("desk", "doctype", child_table, force=True) - - for seq, workspace in enumerate(frappe.get_all("Workspace", order_by="name asc")): + for seq, workspace in enumerate(frappe.get_all("Workspace")): doc = frappe.get_doc("Workspace", workspace.name) content = create_content(doc) update_workspace(doc, seq, content) - frappe.db.commit() def create_content(doc): content = [] - if doc.onboarding: + if doc.get("onboarding"): content.append({"type": "onboarding", "data": {"onboarding_name": doc.onboarding, "col": 12}}) if doc.charts: invalid_links = [] @@ -44,7 +31,7 @@ def create_content(doc): content.append( { "type": "header", - "data": {"text": doc.shortcuts_label or _("Your Shortcuts"), "level": 4, "col": 12}, + "data": {"text": doc.get("shortcuts_label") or _("Your Shortcuts"), "level": 4, "col": 12}, } ) for s in doc.shortcuts: @@ -60,7 +47,7 @@ def create_content(doc): content.append( { "type": "header", - "data": {"text": doc.cards_label or _("Reports & Masters"), "level": 4, "col": 12}, + "data": {"text": doc.get("cards_label") or _("Reports & Masters"), "level": 4, "col": 12}, } ) for l in doc.links: @@ -74,11 +61,16 @@ def create_content(doc): def update_workspace(doc, seq, content): - if not doc.title and not doc.content and not doc.is_standard and not doc.public: + if ( + not doc.title + and (not doc.content or doc.content == "[]") + and not doc.get("is_standard") + and not doc.public + ): doc.sequence_id = seq + 1 doc.content = json.dumps(content) doc.public = 0 if doc.for_user else 1 - doc.title = doc.extends or doc.label + doc.title = doc.get("extends") or doc.get("label") doc.extends = "" doc.category = "" doc.onboarding = "" diff --git a/frappe/permissions.py b/frappe/permissions.py index 431132a0ae..633d0e278d 100644 --- a/frappe/permissions.py +++ b/frappe/permissions.py @@ -48,7 +48,6 @@ def has_permission( doctype, ptype="read", doc=None, - verbose=False, user=None, raise_exception=True, *, @@ -60,7 +59,6 @@ def has_permission( :param doctype: DocType to check permission for :param ptype: Permission Type to check :param doc: Check User Permissions for specified document. - :param verbose: DEPRECATED, will be removed in a future release. :param user: User to check permission for. Defaults to current user. :param raise_exception: DOES NOT raise an exception. @@ -97,6 +95,7 @@ def has_permission( if not perm: push_perm_check_log( _("User {0} does not have access to this document").format(frappe.bold(user)) + + f": {_(doc.doctype)} - {doc.name}" ) else: if ptype == "submit" and not cint(meta.is_submittable): @@ -433,7 +432,7 @@ def get_roles(user=None, with_standard=True): ) return roles + ["All", "Guest"] - roles = frappe.cache().hget("roles", user, get) + roles = frappe.cache.hget("roles", user, get) # filter standard if required if not with_standard: @@ -533,7 +532,7 @@ def update_permission_property(doctype, role, permlevel, ptype, value=None, vali out = setup_custom_perms(doctype) - name = frappe.get_value("Custom DocPerm", dict(parent=doctype, role=role, permlevel=permlevel)) + name = frappe.db.get_value("Custom DocPerm", dict(parent=doctype, role=role, permlevel=permlevel)) table = DocType("Custom DocPerm") frappe.qb.update(table).set(ptype, value).where(table.name == name).run() diff --git a/frappe/public/icons/timeless/icons.svg b/frappe/public/icons/timeless/icons.svg index f5a34a14c9..29aa428d99 100644 --- a/frappe/public/icons/timeless/icons.svg +++ b/frappe/public/icons/timeless/icons.svg @@ -84,13 +84,15 @@ - - + - - + + + + + diff --git a/frappe/public/js/form_builder/FormBuilder.vue b/frappe/public/js/form_builder/FormBuilder.vue index 2a1441c51a..c1ab90c4a4 100644 --- a/frappe/public/js/form_builder/FormBuilder.vue +++ b/frappe/public/js/form_builder/FormBuilder.vue @@ -166,8 +166,7 @@ onMounted(() => { } } - :deep([data-has-std-field="false"]), - :deep([data-is-custom="1"]) { + :deep([data-is-user-generated="1"]) { background-color: var(--yellow-highlight-color); } } @@ -175,7 +174,7 @@ onMounted(() => { :deep(.preview) { --field-placeholder-color: var(--fg-bg-color); - .tab, .column, .field, [data-is-custom="1"] { + .tab, .column, .field { background-color: var(--fg-color); } diff --git a/frappe/public/js/form_builder/components/Column.vue b/frappe/public/js/form_builder/components/Column.vue index acb1ff735e..54dd49d5bf 100644 --- a/frappe/public/js/form_builder/components/Column.vue +++ b/frappe/public/js/form_builder/components/Column.vue @@ -6,7 +6,7 @@ import { ref } from "vue"; import { useStore } from "../store"; import { move_children_to_parent, confirm_dialog } from "../utils"; -let props = defineProps(["section", "column"]); +const props = defineProps(["section", "column"]); let store = useStore(); let hovered = ref(false); @@ -148,8 +148,6 @@ function move_columns_to_section() { :style="{ backgroundColor: column.fields.length ? '' : 'var(--field-placeholder-color)' }" v-model="column.fields" group="fields" - filter="[data-is-custom='0']" - :prevent-on-filter="false" :animation="200" :easing="store.get_animation" item-key="id" @@ -159,7 +157,7 @@ function move_columns_to_section() { diff --git a/frappe/public/js/form_builder/components/EditableInput.vue b/frappe/public/js/form_builder/components/EditableInput.vue index 8964838f4a..21b517af3b 100644 --- a/frappe/public/js/form_builder/components/EditableInput.vue +++ b/frappe/public/js/form_builder/components/EditableInput.vue @@ -3,7 +3,7 @@ import { ref, nextTick, computed } from "vue"; import { useStore } from "../store"; let store = useStore(); -let props = defineProps({ +const props = defineProps({ text: { type: String }, @@ -46,7 +46,7 @@ function focus_on_label() { :disabled="store.read_only" type="text" :placeholder="placeholder" - v-model="text" + :value="text" :style="{ width: hidden_span_width }" @input="event => $emit('update:modelValue', event.target.value)" @keydown.enter="editing = false" diff --git a/frappe/public/js/form_builder/components/Field.vue b/frappe/public/js/form_builder/components/Field.vue index e0230765b5..b67d6db0c9 100644 --- a/frappe/public/js/form_builder/components/Field.vue +++ b/frappe/public/js/form_builder/components/Field.vue @@ -4,7 +4,7 @@ import { ref, computed } from "vue"; import { useStore } from "../store"; import { move_children_to_parent, clone_field } from "../utils"; -let props = defineProps(["column", "field"]); +const props = defineProps(["column", "field"]); let store = useStore(); let hovered = ref(false); diff --git a/frappe/public/js/form_builder/components/Section.vue b/frappe/public/js/form_builder/components/Section.vue index c97fe1e4d8..cd675e5958 100644 --- a/frappe/public/js/form_builder/components/Section.vue +++ b/frappe/public/js/form_builder/components/Section.vue @@ -6,7 +6,7 @@ import { ref } from "vue"; import { useStore } from "../store"; import { section_boilerplate, move_children_to_parent, confirm_dialog } from "../utils"; -let props = defineProps(["tab", "section"]); +const props = defineProps(["tab", "section"]); let store = useStore(); let hovered = ref(false); @@ -160,8 +160,6 @@ function move_sections_to_tab() { backgroundColor: section.columns.length ? null : 'var(--field-placeholder-color)' }" v-model="section.columns" - filter="[data-has-std-field='true']" - :prevent-on-filter="false" group="columns" item-key="id" :animation="200" @@ -172,8 +170,7 @@ function move_sections_to_tab() { diff --git a/frappe/public/js/form_builder/components/Tabs.vue b/frappe/public/js/form_builder/components/Tabs.vue index b587d9d37e..5c233dbd1b 100644 --- a/frappe/public/js/form_builder/components/Tabs.vue +++ b/frappe/public/js/form_builder/components/Tabs.vue @@ -114,8 +114,6 @@ function delete_tab(with_children) { class="tabs" v-model="store.form.layout.tabs" group="tabs" - filter="[data-has-std-field='true']" - :prevent-on-filter="false" :animation="200" :easing="store.get_animation" item-key="id" @@ -125,8 +123,7 @@ function delete_tab(with_children) {
diff --git a/frappe/public/js/form_builder/components/controls/AttachControl.vue b/frappe/public/js/form_builder/components/controls/AttachControl.vue index 86cdf7c5ac..6d8718d5dc 100644 --- a/frappe/public/js/form_builder/components/controls/AttachControl.vue +++ b/frappe/public/js/form_builder/components/controls/AttachControl.vue @@ -1,6 +1,6 @@