diff --git a/.github/workflows/build-and-commit-assets.yml b/.github/workflows/build-and-commit-assets.yml new file mode 100644 index 000000000000..c57953cfd0d0 --- /dev/null +++ b/.github/workflows/build-and-commit-assets.yml @@ -0,0 +1,60 @@ +name: Build and Upload Assets + +on: + push: + branches: + - develop + - 'version-*' + +concurrency: + group: build-assets-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: write + +jobs: + build-assets: + name: Build JS/CSS and upload to release + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + path: apps/frappe + + - name: Create bench structure + run: | + mkdir -p sites + echo "frappe" > sites/apps.txt + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: yarn + cache-dependency-path: apps/frappe/yarn.lock + + - name: Install JS dependencies + working-directory: apps/frappe + run: yarn install --frozen-lockfile + + - name: Link node_modules into public/ + working-directory: apps/frappe + run: ln -s "$PWD/node_modules" frappe/public/node_modules + + - name: Build assets (production) + working-directory: apps/frappe + run: yarn run production + + - name: Package assets + working-directory: apps/frappe + run: tar czf frappe-assets.tar.gz -C ../../sites/assets/frappe dist + + - name: Upload to rolling release + working-directory: apps/frappe + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="assets-${GITHUB_REF_NAME//\//-}" + gh release create "$TAG" --prerelease --title "Assets: $GITHUB_REF_NAME" --notes "" 2>/dev/null || true + gh release upload "$TAG" frappe-assets.tar.gz --clobber diff --git a/.github/workflows/publish-assets-develop.yml b/.github/workflows/publish-assets-develop.yml deleted file mode 100644 index 2d60df6c70cb..000000000000 --- a/.github/workflows/publish-assets-develop.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: 'Frappe Assets' - -on: - workflow_dispatch: - push: - branches: [ develop ] - -jobs: - build-dev-and-publish: - name: 'Build and Publish Assets for Development' - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v6 - with: - path: 'frappe' - - uses: actions/setup-node@v6 - with: - node-version: 24 - - uses: actions/setup-python@v6 - with: - python-version: '3.14' - - name: Set up bench and build assets - run: | - npm install -g yarn - pip3 install -U frappe-bench - bench -v init frappe-bench --no-procfile --no-backups --skip-assets --skip-redis-config-generation --python $(which python) --frappe-path $GITHUB_WORKSPACE/frappe - cd frappe-bench && bench build - - - name: Package assets - run: | - mkdir -p $GITHUB_WORKSPACE/build - tar -cvpzf $GITHUB_WORKSPACE/build/$GITHUB_SHA.tar.gz ./frappe-bench/sites/assets/frappe/dist - - - name: Publish assets to S3 - uses: jakejarvis/s3-sync-action@master - with: - args: --acl public-read - env: - AWS_S3_BUCKET: 'assets.frappeframework.com' - AWS_ACCESS_KEY_ID: ${{ secrets.S3_ASSETS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_ASSETS_SECRET_ACCESS_KEY }} - AWS_S3_ENDPOINT: 'http://s3.fr-par.scw.cloud' - AWS_REGION: 'fr-par' - SOURCE_DIR: '$GITHUB_WORKSPACE/build' diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml index 71d3a359dd7c..22bd23c6492d 100644 --- a/.github/workflows/server-tests.yml +++ b/.github/workflows/server-tests.yml @@ -65,8 +65,8 @@ jobs: coverage: name: Coverage Wrap Up needs: [test, checkrun] + if: ${{ needs.checkrun.outputs.build == 'strawberry' && github.event_name != 'pull_request' }} runs-on: ubuntu-latest - if: ${{ github.event_name != 'pull_request' }} steps: - name: Clone uses: actions/checkout@v6 diff --git a/cypress/integration/global_search_settings.js b/cypress/integration/global_search_settings.js new file mode 100644 index 000000000000..3da3f98b037f --- /dev/null +++ b/cypress/integration/global_search_settings.js @@ -0,0 +1,129 @@ +context("Global Search Settings — configure search fields", () => { + const GS_GRID = '.frappe-control[data-fieldname="allowed_in_global_search"]'; + + /** Editable-grid Link fields only mount after the row is active (toggle_editable_row → make_control). */ + function ensure_first_priority_row() { + cy.get(`${GS_GRID} .grid-body`).then(($body) => { + if ($body.find(".grid-row").length === 0) { + cy.get(`${GS_GRID} .grid-add-row`).click(); + } + }); + cy.get(`${GS_GRID} .grid-body .grid-row[data-idx="1"]`).should("exist"); + } + + function activate_document_type_cell(rowAlias = "@row") { + cy.get(rowAlias).find('[data-fieldname="document_type"]').click(); + cy.get(rowAlias).find('[data-fieldname="document_type"] input').should("exist"); + } + + /** Awesomplete dropdown is attached via `aria-owns` (often outside the row); items are `div[role="option"]`, not `li`. */ + function select_document_type_link(rowAlias, label) { + cy.get(rowAlias).find('[data-fieldname="document_type"] input').as("docTypeInput"); + cy.get("@docTypeInput").clear().focus().type(label, { delay: 100 }); + cy.get("@docTypeInput").invoke("attr", "aria-owns").should("match", /\w+/); + cy.get("@docTypeInput") + .invoke("attr", "aria-owns") + .then((ownsId) => { + const sel = `#${CSS.escape(ownsId)}`; + cy.get(sel).should("be.visible"); + cy.get(sel) + .find('[role="option"]') + .filter((_, el) => { + const $el = Cypress.$(el); + const primary = + $el.find("strong").first().text().trim() || + $el.text().trim().split("\n")[0]; + return primary === label; + }) + .should("have.length", 1) + .scrollIntoView() + .click({ force: true }); + }); + cy.get("@docTypeInput").blur(); + cy.get("@docTypeInput").should("have.value", label); + } + + beforeEach(() => { + cy.login("Administrator", Cypress.env("adminPassword") || "admin"); + cy.visit("/desk/global-search-settings"); + cy.get("body").should("have.attr", "data-ajax-state", "complete"); + cy.get(`${GS_GRID}`).should("exist"); + }); + + it("shows a message when Configure is clicked without Document Type", () => { + cy.get('.frappe-control[data-fieldname="allowed_in_global_search"]') + .find(".grid-add-row") + .click(); + cy.get( + '.frappe-control[data-fieldname="allowed_in_global_search"] .grid-body .grid-row:last' + ) + .find('[data-fieldname="configure"] button') + .click(); + cy.get(".msgprint").should("contain", "Please select Document Type first"); + }); + + it("opens configure dialog with MultiCheck field options and filter search", () => { + ensure_first_priority_row(); + cy.get(`${GS_GRID} .grid-body .grid-row[data-idx="1"]`).as("row"); + activate_document_type_cell(); + select_document_type_link("@row", "ToDo"); + + cy.get("@row").find('[data-fieldname="configure"] button').click(); + + cy.get_open_dialog().find(".modal-title").should("contain", "Configure search fields"); + cy.get_open_dialog().should("contain", "ToDo"); + cy.get_open_dialog() + .find('.checkbox-options input[type="checkbox"][data-unit="name"]') + .should("exist"); + + const unlikely = "xyz-nonmatching-global-search-filter-12345"; + cy.get_open_dialog().find('[data-element="search"]').clear().type(unlikely); + cy.get_open_dialog() + .find(".checkbox-options .unit-checkbox:visible") + .should("have.length", 0); + cy.get_open_dialog().find('[data-element="search"]').clear(); + cy.get_open_dialog() + .find(".checkbox-options .unit-checkbox:visible") + .should((els) => { + expect(els.length).to.be.greaterThan(0); + }); + + cy.get_open_dialog().find(".btn-modal-close").click(); + cy.get(".modal:visible").should("not.exist"); + }); + + it("saves selected fields and shows success toast", () => { + cy.intercept( + "POST", + "/api/method/frappe.desk.doctype.global_search_settings.global_search_settings.update_global_search_fields" + ).as("update_global_search_fields"); + + ensure_first_priority_row(); + cy.get(`${GS_GRID} .grid-body .grid-row[data-idx="1"]`).as("row"); + activate_document_type_cell(); + // Must not be a Core module DocType — API rejects those (no toast; server error). + select_document_type_link("@row", "ToDo"); + + cy.get("@row").find('[data-fieldname="configure"] button').click(); + + cy.get_open_dialog() + .find('.checkbox-options input[type="checkbox"][data-unit="name"]') + .should("exist"); + + cy.get_open_dialog() + .find(".modal-footer .standard-actions .btn-primary") + .contains("Save") + .click({ force: true }); + + cy.wait("@update_global_search_fields").then(({ response }) => { + expect(response.statusCode).to.eq(200); + expect(response.body.exc, JSON.stringify(response.body)).to.be.undefined; + expect(response.body.message?.success).to.eq(true); + }); + + cy.get('[role="alert"].desk-alert .alert-message', { timeout: 25000 }).should( + "contain", + "Search fields updated." + ); + }); +}); diff --git a/cypress/integration/grid.js b/cypress/integration/grid.js index 607db6746eae..0e951edd0402 100644 --- a/cypress/integration/grid.js +++ b/cypress/integration/grid.js @@ -111,4 +111,90 @@ context("Grid", () => { cy.get("@table-form").find(".grid-footer-toolbar").click(); }); }); + + it("shows edit button only when child table allow_bulk_edit is enabled", () => { + cy.visit("/desk/contact/Test Contact"); + cy.get('.frappe-control[data-fieldname="phone_nos"]').as("table"); + + cy.window() + .its("cur_frm") + .then((frm) => { + const grid = frm.get_field("phone_nos").grid; + grid.meta.allow_bulk_edit = false; + grid.refresh_edit_rows_button(); + }); + + cy.get("@table").find('.grid-row[data-idx="1"] .grid-row-check').click({ force: true }); + cy.get("@table").find(".grid-edit-rows").should("have.class", "hidden"); + + cy.window() + .its("cur_frm") + .then((frm) => { + const grid = frm.get_field("phone_nos").grid; + grid.meta.allow_bulk_edit = true; + grid.refresh_edit_rows_button(); + }); + + cy.get("@table").find(".grid-edit-rows").should("not.have.class", "hidden"); + }); + + it("bulk edit updates only selected child rows", () => { + const updated_phone = `99999${Date.now().toString().slice(-5)}`; + + cy.visit("/desk/contact/Test Contact"); + cy.get('.frappe-control[data-fieldname="phone_nos"]').as("table"); + + cy.window() + .its("cur_frm") + .then((frm) => { + const grid = frm.get_field("phone_nos").grid; + grid.meta.allow_bulk_edit = true; + grid.refresh_edit_rows_button(); + + expect(frm.doc.phone_nos.length).to.be.greaterThan(1); + const phone_df = grid.docfields.find((df) => df.fieldname === "phone"); + expect(phone_df).to.exist; + cy.wrap(phone_df.label).as("phoneFieldLabel"); + cy.wrap(frm.doc.phone_nos[1].phone || "").as("secondRowPhoneBefore"); + }); + + cy.get("@table").find('.grid-row[data-idx="1"] .grid-row-check').click({ force: true }); + cy.get("@table").find(".grid-edit-rows").click({ force: true }); + + cy.window() + .its("cur_dialog") + .then((dialog) => { + cy.get("@phoneFieldLabel").then((phoneFieldLabel) => { + return dialog + .set_value("field", phoneFieldLabel) + .then(() => dialog.set_value("value", updated_phone)) + .then(() => { + dialog.get_primary_btn().click(); + }); + }); + }); + + cy.window().its("cur_frm.doc.phone_nos.0.phone").should("eq", updated_phone); + cy.window() + .its("cur_frm") + .then((frm) => { + cy.get("@secondRowPhoneBefore").then((secondRowPhoneBefore) => { + expect(frm.doc.phone_nos[1].phone || "").to.equal(secondRowPhoneBefore); + }); + }); + }); + + it("hides add-row and add-multiple-rows buttons when rows are selected", () => { + cy.visit("/desk/contact/Test Contact"); + cy.get('.frappe-control[data-fieldname="phone_nos"]').as("table"); + + cy.get("@table").find('.grid-row[data-idx="1"] .grid-row-check').click({ force: true }); + + cy.get("@table").find(".grid-add-row").should("have.class", "hidden"); + cy.get("@table").find(".grid-add-multiple-rows").should("have.class", "hidden"); + + cy.get("@table").find('.grid-row[data-idx="1"] .grid-row-check').click({ force: true }); + + cy.get("@table").find(".grid-add-row").should("not.have.class", "hidden"); + }); }); diff --git a/cypress/integration/grid_row_form_tabs.js b/cypress/integration/grid_row_form_tabs.js index c9952a304250..74560cc9252b 100644 --- a/cypress/integration/grid_row_form_tabs.js +++ b/cypress/integration/grid_row_form_tabs.js @@ -112,6 +112,29 @@ context("Grid Row Form Tabs", () => { cy.get("@table-form2").find(".form-tabs .nav-link").first().should("have.class", "active"); }); + it("should jump to a field inside the grid row form", () => { + cy.new_form(parent_doctype_name); + cy.fill_field("title", "Test Jump To Field"); + + // Add a row and open the grid row form + cy.get('.frappe-control[data-fieldname="items"]').as("table"); + cy.get("@table").findByRole("button", { name: "Add row" }).click(); + cy.get("@table").find('[data-idx="1"]').find(".btn-open-row").click(); + cy.get(".grid-row-open").as("table-form"); + + // Jump to a field that lives on a different tab (Details > Notes) + cy.get("body").type("{esc}").type("{ctrl+j}"); + cy.get(".modal input[type='text']").first().focus(); + cy.get("body").type("Notes").wait(1000).type("{enter}").wait(200); + cy.findByRole("button", { name: "Go" }).click().wait(500); + + // Grid row form stays open and the target field is focused + cy.get(".grid-row-open").should("exist"); + cy.get("@table-form") + .find('.frappe-control[data-fieldname="notes"] input') + .should("be.focused"); + }); + it("should allow data entry in fields across different tabs", () => { cy.new_form(parent_doctype_name); cy.fill_field("title", "Test Data Entry"); diff --git a/cypress/integration/query_report.js b/cypress/integration/query_report.js index 8df908683cc7..62af660d645d 100644 --- a/cypress/integration/query_report.js +++ b/cypress/integration/query_report.js @@ -39,8 +39,17 @@ context("Query Report", () => { .click({ force: true }); cy.get_open_dialog().get(".modal-title").should("contain", "Add Column"); cy.get('select[data-fieldname="doctype"]').select("Role (Name)", { force: true }); - cy.get('select[data-fieldname="field"]').select("Role Name", { force: true }); - cy.get('select[data-fieldname="insert_after"]').select("Name", { force: true }); + cy.wait(500); + cy.get_open_dialog() + .find('.control-input > .awesomplete > input[data-fieldname="field"]') + .should("be.visible") + .clear({ force: true }) + .type("Role Name{enter}", { delay: 150, force: true }); + cy.get_open_dialog() + .find('.control-input > .awesomplete > input[data-fieldname="insert_after"]') + .should("be.visible") + .clear({ force: true }) + .type("Name{enter}", { delay: 150, force: true }); cy.get_open_dialog() .findByRole("button", { name: "Submit" }) .click({ force: true }); diff --git a/cypress/integration/rounding.js b/cypress/integration/rounding.js index 89e305b1f61e..fc7cd50ead0f 100644 --- a/cypress/integration/rounding.js +++ b/cypress/integration/rounding.js @@ -99,6 +99,17 @@ context("Rounding behaviour", () => { expect(flt(-1.15, 1, null, rounding_method)).eq(-1.2); expect(flt(-2.25, 1, null, rounding_method)).eq(-2.2); expect(flt(-3.35, 1, null, rounding_method)).eq(-3.4); + + // Sign-symmetry regression + for (const [value, expected] of [ + [647.325, 647.32], + [647.315, 647.32], + [0.125, 0.12], + [0.135, 0.14], + ]) { + expect(flt(value, 2, null, rounding_method)).eq(expected); + expect(flt(-value, 2, null, rounding_method)).eq(-expected); + } }); }); }); diff --git a/cypress/integration/web_form.js b/cypress/integration/web_form.js index 5f1c4474a5fb..36f65a80bc79 100644 --- a/cypress/integration/web_form.js +++ b/cypress/integration/web_form.js @@ -72,10 +72,8 @@ context("Web Form", () => { cy.call("logout"); - cy.visit("/note"); - cy.get_open_dialog() - .get(".modal-message") - .contains("You are not permitted to access this page without login."); + cy.visit("/note", { failOnStatusCode: false }); + cy.contains("You must be logged in to use this form."); }); it("Show List", () => { diff --git a/frappe/__init__.py b/frappe/__init__.py index fd0d2d59907f..2b936fdcb7e0 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -55,7 +55,7 @@ render_template, ) -__version__ = "16.18.2" +__version__ = "16.22.0" __title__ = "Frappe Framework" if TYPE_CHECKING: # pragma: no cover @@ -196,7 +196,8 @@ def init(site: str, sites_path: str = ".", new_site: bool = False, force: bool = local.new_doc_templates = {} local.request_cache = defaultdict(dict) - local.jenv = None + local.jenv_restricted = None + local.jenv_unrestricted = None local.jloader = None local.cache = {} local.form_dict = _dict() @@ -371,7 +372,8 @@ def set_user(username: str): local.session.sid = username local.cache = {} local.form_dict = _dict() - local.jenv = None + local.jenv_restricted = None + local.jenv_unrestricted = None local.session.data = _dict() local.role_permissions = {} local.new_doc_templates = {} diff --git a/frappe/app.py b/frappe/app.py index 7f4e4bcfacc3..5d319a34cbf5 100644 --- a/frappe/app.py +++ b/frappe/app.py @@ -478,6 +478,7 @@ def sync_database(): auto_enabling_integrations=False, default_integrations=False, integrations=integrations, + include_local_variables=False, _experiments=experiments, **kwargs, ) diff --git a/frappe/automation/doctype/assignment_rule/assignment_rule.py b/frappe/automation/doctype/assignment_rule/assignment_rule.py index 1a26a6d77ca9..4516d1e9a9fc 100644 --- a/frappe/automation/doctype/assignment_rule/assignment_rule.py +++ b/frappe/automation/doctype/assignment_rule/assignment_rule.py @@ -83,7 +83,7 @@ def do_assignment(self, doc): if not user or not frappe.db.exists("User", user): return False - assign_to.add( + assign_to._add( dict( assign_to=[user], doctype=doc.get("doctype"), diff --git a/frappe/boot.py b/frappe/boot.py index 2ee990ed2a8d..0ae8a64664bc 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -13,6 +13,7 @@ get_setup_wizard_completed_apps, ) from frappe.core.doctype.navbar_settings.navbar_settings import get_app_logo, get_navbar_settings +from frappe.desk.desk_views import DeskViews from frappe.desk.doctype.changelog_feed.changelog_feed import get_changelog_feed_items from frappe.desk.doctype.desktop_icon.desktop_icon import get_desktop_icons from frappe.desk.doctype.form_tour.form_tour import get_onboarding_ui_tours @@ -21,11 +22,7 @@ from frappe.email.inbox import get_email_accounts from frappe.integrations.frappe_providers.frappecloud_billing import current_site_info, is_fc_site from frappe.model.base_document import get_controller -from frappe.permissions import has_permission -from frappe.query_builder import DocType -from frappe.query_builder.functions import Count -from frappe.query_builder.terms import ParameterizedValueWrapper, SubQuery -from frappe.utils import add_user_info, cstr, get_system_timezone +from frappe.utils import add_user_info, get_system_timezone from frappe.utils.caching import redis_cache from frappe.utils.change_log import get_versions from frappe.utils.frappecloud import on_frappecloud @@ -57,6 +54,9 @@ def get_bootinfo(): bootinfo.modules = {} bootinfo.module_list = [] + desk_views = DeskViews() + desk_views.build_entities() + desk_views.add_to_boot(bootinfo) load_desktop_data(bootinfo) bootinfo.desktop_icons = get_desktop_icons(bootinfo=bootinfo) bootinfo.letter_heads = get_letter_heads() @@ -69,7 +69,6 @@ def get_bootinfo(): bootinfo.nested_set_doctypes = frappe.get_all("DocField", {"fieldname": "lft"}, pluck="parent") bootinfo.tree_view_doctypes = get_tree_view_doctypes() add_home_page(bootinfo, doclist) - bootinfo.page_info = get_allowed_pages() load_translations(bootinfo) add_timezone_info(bootinfo) load_conf_settings(bootinfo) @@ -160,13 +159,9 @@ def load_conf_settings(bootinfo): def load_desktop_data(bootinfo): - from frappe.desk.desktop import get_workspace_sidebar_items - - bootinfo.workspaces = get_workspace_sidebar_items() allowed_pages = [d.name for d in bootinfo.workspaces.get("pages")] bootinfo.workspace_sidebar_item = get_sidebar_items(allowed_pages) bootinfo.module_wise_workspaces = get_controller("Workspace").get_module_wise_workspaces() - bootinfo.dashboards = frappe.get_all("Dashboard") bootinfo.app_data = [] Workspace = frappe.qb.DocType("Workspace") @@ -222,124 +217,6 @@ def load_desktop_data(bootinfo): ) -def get_allowed_pages(cache=False): - return get_user_pages_or_reports("Page", cache=cache) - - -def get_allowed_reports(cache=False): - return get_user_pages_or_reports("Report", cache=cache) - - -def get_allowed_report_names(cache=False) -> set[str]: - return {cstr(report) for report in get_allowed_reports(cache).keys() if report} - - -def get_user_pages_or_reports(parent, cache=False): - if cache: - has_role = frappe.cache.get_value("has_role:" + parent, user=frappe.session.user) - if has_role: - return has_role - - roles = frappe.get_roles() - has_role = {} - - page = DocType("Page") - report = DocType("Report") - - is_report = parent == "Report" - - if is_report: - columns = (report.name.as_("title"), report.ref_doctype, report.report_type) - else: - columns = (page.title.as_("title"),) - - customRole = DocType("Custom Role") - hasRole = DocType("Has Role") - parentTable = DocType(parent) - - # get pages or reports set on custom role - pages_with_custom_roles = ( - frappe.qb.from_(customRole) - .from_(hasRole) - .from_(parentTable) - .select(customRole[parent.lower()].as_("name"), customRole.modified, customRole.ref_doctype, *columns) - .where( - (hasRole.parent == customRole.name) - & (parentTable.name == customRole[parent.lower()]) - & (customRole[parent.lower()].isnotnull()) - & (hasRole.role.isin(roles)) - ) - ).run(as_dict=True) - - for p in pages_with_custom_roles: - has_role[p.name] = {"modified": p.modified, "title": p.title, "ref_doctype": p.ref_doctype} - - subq = ( - frappe.qb.from_(customRole) - .select(customRole[parent.lower()]) - .where(customRole[parent.lower()].isnotnull()) - ) - - pages_with_standard_roles = ( - frappe.qb.from_(hasRole) - .from_(parentTable) - .select(parentTable.name.as_("name"), parentTable.modified, *columns) - .where( - (hasRole.role.isin(roles)) & (hasRole.parent == parentTable.name) & (parentTable.name.notin(subq)) - ) - .distinct() - ) - - if is_report: - pages_with_standard_roles = pages_with_standard_roles.where(report.disabled == 0) - - pages_with_standard_roles = pages_with_standard_roles.run(as_dict=True) - - for p in pages_with_standard_roles: - if p.name not in has_role: - has_role[p.name] = {"modified": p.modified, "title": p.title} - if parent == "Report": - has_role[p.name].update({"ref_doctype": p.ref_doctype}) - - no_of_roles = SubQuery( - frappe.qb.from_(hasRole).select(Count("*")).where(hasRole.parent == parentTable.name) - ) - - # pages and reports with no role are allowed - rows_with_no_roles = ( - frappe.qb.from_(parentTable) - .select(parentTable.name, parentTable.modified, *columns) - .where(no_of_roles == 0) - ).run(as_dict=True) - - for r in rows_with_no_roles: - if r.name not in has_role: - has_role[r.name] = {"modified": r.modified, "title": r.title} - if is_report: - has_role[r.name] |= {"ref_doctype": r.ref_doctype} - - if is_report: - if not has_permission("Report", print_logs=False): - return {} - - reports = frappe.get_list( - "Report", - fields=["name", "report_type"], - filters={"name": ("in", has_role.keys())}, - ignore_ifnull=True, - ) - for report in reports: - has_role[report.name]["report_type"] = report.report_type - - non_permitted_reports = set(has_role.keys()) - {r.name for r in reports} - for r in non_permitted_reports: - has_role.pop(r, None) - - # Expire every six hours - 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 @@ -559,56 +436,60 @@ def get_sidebar_items(allowed_workspaces): else: sidebar_title = sidebar.title sidebar_doc = sidebar - if ( - frappe.session.user == "Administrator" - or sidebar_title == "My Workspaces" - or not sidebar_doc.module - or sidebar_doc.module in sidebar_doc.user.allow_modules - ): - sidebar_items[sidebar_title.lower()] = { - "label": sidebar_title, - "items": [], - "header_icon": sidebar.get("header_icon"), - "module_onboarding": sidebar.get("module_onboarding"), - "module": sidebar_doc.module, - "app": sidebar_doc.app, + is_my_workspaces = "My Workspaces" in sidebar_title + items = [] + for item in sidebar_doc.items: + workspace_sidebar = { + "label": _(item.label), + "link_to": item.link_to, + "link_type": item.link_type, + "type": item.type, + "icon": item.icon, + "child": item.child, + "collapsible": item.collapsible, + "indent": item.indent, + "keep_closed": item.keep_closed, + "url": item.url, + "show_arrow": item.show_arrow, + "filters": item.filters, + "route_options": item.route_options, + "tab": item.navigate_to_tab, } - for item in sidebar_doc.items: - workspace_sidebar = { - "label": _(item.label), - "link_to": item.link_to, - "link_type": item.link_type, - "type": item.type, - "icon": item.icon, - "child": item.child, - "collapsible": item.collapsible, - "indent": item.indent, - "keep_closed": item.keep_closed, - "url": item.url, - "show_arrow": item.show_arrow, - "filters": item.filters, - "route_options": item.route_options, - "tab": item.navigate_to_tab, + if ( + item.link_type == "Report" + and item.link_to + and frappe.db.exists("Report", item.link_to) + and not frappe.db.get_value("Report", item.link_to, "disabled") + ): + report_type, ref_doctype = frappe.db.get_value( + "Report", item.link_to, ["report_type", "ref_doctype"] + ) + workspace_sidebar["report"] = { + "report_type": report_type, + "ref_doctype": ref_doctype, } - if ( - item.link_type == "Report" - and item.link_to - and frappe.db.exists("Report", item.link_to) - and not frappe.db.get_value("Report", item.link_to, "disabled") - ): - report_type, ref_doctype = frappe.db.get_value( - "Report", item.link_to, ["report_type", "ref_doctype"] - ) - workspace_sidebar["report"] = { - "report_type": report_type, - "ref_doctype": ref_doctype, - } - if ( - "My Workspaces" in sidebar_title - or item.type == "Section Break" - or sidebar_doc.is_item_allowed(item.link_to, item.link_type, allowed_workspaces) - ): - sidebar_items[sidebar_title.lower()]["items"].append(workspace_sidebar) + if ( + is_my_workspaces + or item.type == "Section Break" + or sidebar_doc.is_item_allowed(item.link_to, item.link_type, allowed_workspaces) + ): + items.append(workspace_sidebar) + + # A sidebar (and its desktop icon) is shown only if the user can see at least one + # real item in it, i.e. a non-Section-Break item survived the per-item filter above. + # This is the single source of truth for sidebar permissions and mirrors + # Desktop Icon.is_permitted. "My Workspaces" is always shown. + if not is_my_workspaces and not any(item["type"] != "Section Break" for item in items): + continue + + sidebar_items[sidebar_title.lower()] = { + "label": sidebar_title, + "items": items, + "header_icon": sidebar.get("header_icon"), + "module_onboarding": sidebar.get("module_onboarding"), + "module": sidebar_doc.module, + "app": sidebar_doc.app, + } add_user_specific_sidebar(sidebar_items) return sidebar_items diff --git a/frappe/commands/test_commands.py b/frappe/commands/test_commands.py index cab51b7034d6..a4e09764e1b0 100644 --- a/frappe/commands/test_commands.py +++ b/frappe/commands/test_commands.py @@ -940,7 +940,7 @@ def test_create_user(self): class TestBenchBuild(IntegrationTestCase): def test_build_assets_size_check(self): - CURRENT_SIZE = 3.41 # MB + CURRENT_SIZE = 3.45 # MB JS_ASSET_THRESHOLD = 0.01 hooks = frappe.get_hooks() diff --git a/frappe/contacts/doctype/address/address.json b/frappe/contacts/doctype/address/address.json index 3e147cc20595..118385d4edd5 100644 --- a/frappe/contacts/doctype/address/address.json +++ b/frappe/contacts/doctype/address/address.json @@ -92,6 +92,7 @@ { "fieldname": "pincode", "fieldtype": "Data", + "hidden": 1, "label": "Postal Code", "search_index": 1 }, @@ -103,6 +104,7 @@ { "fieldname": "email_id", "fieldtype": "Data", + "hidden": 1, "label": "Email Address", "options": "Email" }, @@ -115,6 +117,7 @@ { "fieldname": "fax", "fieldtype": "Data", + "hidden": 1, "label": "Fax" }, { @@ -151,7 +154,7 @@ "icon": "fa fa-map-marker", "idx": 5, "links": [], - "modified": "2024-03-23 16:01:27.068028", + "modified": "2026-05-28", "modified_by": "Administrator", "module": "Contacts", "name": "Address", @@ -167,16 +170,6 @@ "share": 1, "write": 1 }, - { - "create": 1, - "email": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "Purchase User", - "share": 1, - "write": 1 - }, { "create": 1, "email": 1, @@ -212,9 +205,7 @@ }, { "create": 1, - "export": 1, "if_owner": 1, - "print": 1, "read": 1, "role": "All", "write": 1 diff --git a/frappe/contacts/doctype/contact/contact.json b/frappe/contacts/doctype/contact/contact.json index 8011d5f4e85f..31b855b1801e 100644 --- a/frappe/contacts/doctype/contact/contact.json +++ b/frappe/contacts/doctype/contact/contact.json @@ -20,6 +20,7 @@ "cb00", "status", "salutation", + "department", "designation", "gender", "phone", @@ -38,7 +39,6 @@ "links", "is_primary_contact", "more_info", - "department", "unsubscribed" ], "fields": [ @@ -166,6 +166,7 @@ "default": "0", "fieldname": "unsubscribed", "fieldtype": "Check", + "hidden": 1, "label": "Unsubscribed" }, { @@ -216,6 +217,7 @@ "default": "0", "fieldname": "sync_with_google_contacts", "fieldtype": "Check", + "hidden": 1, "label": "Sync with Google Contacts" }, { @@ -257,7 +259,7 @@ "image_field": "image", "index_web_pages_for_search": 1, "links": [], - "modified": "2024-03-23 16:01:30.937045", + "modified": "2026-06-08", "modified_by": "Administrator", "module": "Contacts", "name": "Contact", @@ -350,16 +352,6 @@ "share": 1, "write": 1 }, - { - "create": 1, - "email": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "Purchase User", - "share": 1, - "write": 1 - }, { "create": 1, "email": 1, @@ -382,10 +374,8 @@ }, { "create": 1, - "delete": 1, "if_owner": 1, "read": 1, - "report": 1, "role": "All", "write": 1 } diff --git a/frappe/contacts/doctype/contact/contact.py b/frappe/contacts/doctype/contact/contact.py index 3ad5e54c62b5..65088b0ee2da 100644 --- a/frappe/contacts/doctype/contact/contact.py +++ b/frappe/contacts/doctype/contact/contact.py @@ -348,8 +348,8 @@ def contact_query(doctype, txt, searchfield, start, page_len, filters): if not frappe.get_meta(doctype).get_field(searchfield) and searchfield not in frappe.db.DEFAULT_COLUMNS: return [] - link_doctype = filters.pop("link_doctype") - link_name = filters.pop("link_name") + link_doctype = filters.pop("link_doctype", None) + link_name = filters.pop("link_name", None) return frappe.db.sql( f"""select diff --git a/frappe/core/doctype/communication/communication.json b/frappe/core/doctype/communication/communication.json index 296e285445d6..5e5a17dc6e56 100644 --- a/frappe/core/doctype/communication/communication.json +++ b/frappe/core/doctype/communication/communication.json @@ -372,7 +372,7 @@ "idx": 1, "links": [], "make_attachments_public": 1, - "modified": "2025-12-25 19:19:29.427081", + "modified": "2026-05-28", "modified_by": "Administrator", "module": "Core", "name": "Communication", @@ -418,13 +418,6 @@ "print": 1, "read": 1, "role": "Inbox User" - }, - { - "delete": 1, - "email": 1, - "if_owner": 1, - "read": 1, - "role": "All" } ], "row_format": "Compressed", diff --git a/frappe/core/doctype/communication/communication.py b/frappe/core/doctype/communication/communication.py index 16dae174ec0f..450853044054 100644 --- a/frappe/core/doctype/communication/communication.py +++ b/frappe/core/doctype/communication/communication.py @@ -447,11 +447,19 @@ def deduplicate_timeline_links(self): self.add_link(doctype, name) def add_link(self, link_doctype, link_name, autosave=False): + title_field = frappe.get_meta(link_doctype).get_title_field() + link_title = ( + frappe.db.get_value(link_doctype, link_name, title_field, cache=True, order_by=None) + if title_field != "name" + else None + ) + self.append( "timeline_links", { "link_doctype": link_doctype, "link_name": link_name, + "link_title": link_title or link_name, "communication_date": self.communication_date, }, ) diff --git a/frappe/core/doctype/data_import/data_import.js b/frappe/core/doctype/data_import/data_import.js index af8e44e6de3d..766c73566d9a 100644 --- a/frappe/core/doctype/data_import/data_import.js +++ b/frappe/core/doctype/data_import/data_import.js @@ -406,7 +406,9 @@ frappe.ui.form.on("Data Import", { let column_number = `${__("Column {0}", [ warning.col, ])}`; - let column_header = columns[warning.col].header_title; + let column_header = frappe.utils.escape_html( + columns[warning.col].header_title + ); header = `${column_number} (${column_header})`; } return ` diff --git a/frappe/core/doctype/data_import/data_import.json b/frappe/core/doctype/data_import/data_import.json index 5a96c8546632..7b1b23e21d66 100644 --- a/frappe/core/doctype/data_import/data_import.json +++ b/frappe/core/doctype/data_import/data_import.json @@ -1,7 +1,6 @@ { "actions": [], "autoname": "format:{reference_doctype} Import on {creation}", - "beta": 1, "creation": "2019-08-04 14:16:08.318714", "doctype": "DocType", "editable_grid": 1, @@ -135,6 +134,7 @@ "default": "1", "fieldname": "mute_emails", "fieldtype": "Check", + "hidden": 1, "label": "Don't Send Emails", "set_only_once": 1 }, @@ -195,11 +195,11 @@ ], "hide_toolbar": 1, "links": [], - "modified": "2025-01-14 17:36:22.389195", + "modified": "2026-05-30", "modified_by": "Administrator", "module": "Core", "name": "Data Import", - "naming_rule": "Expression", + "naming_rule": "Expression (old style)", "owner": "Administrator", "permissions": [ { @@ -215,8 +215,9 @@ "write": 1 } ], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/frappe/core/doctype/data_import/exporter.py b/frappe/core/doctype/data_import/exporter.py index 4cb63651e3e3..67a82807e279 100644 --- a/frappe/core/doctype/data_import/exporter.py +++ b/frappe/core/doctype/data_import/exporter.py @@ -124,14 +124,14 @@ def get_data_to_export(self): raise frappe.PermissionError( _("You are not allowed to export {} doctype").format(self.doctype) ) - for doc in data: rows = [] rows = self.add_data_row(self.doctype, None, doc, rows, 0) if table_fields: # add child table data for f in table_fields: - for i, child_row in enumerate(doc.get(f, [])): + table_data = doc.get(f, []) or [] + for i, child_row in enumerate(table_data): table_df = self.meta.get_field(f) child_doctype = table_df.options rows = self.add_data_row(child_doctype, child_row.parentfield, child_row, rows, i) diff --git a/frappe/core/doctype/data_import/importer.py b/frappe/core/doctype/data_import/importer.py index 9db4273d6eda..4183693ea1c7 100644 --- a/frappe/core/doctype/data_import/importer.py +++ b/frappe/core/doctype/data_import/importer.py @@ -615,7 +615,7 @@ def read_content(self, content, extension): if extension == "csv": data = read_csv_content(content, use_sniffer=self.use_sniffer) elif extension == "xlsx": - data = read_xlsx_file_from_attached_file(fcontent=content) + data = read_xlsx_file_from_attached_file(fcontent=content, read_only=True) elif extension == "xls": data = read_xls_file_from_attached_file(content) return data @@ -926,7 +926,8 @@ def parse(self): self.warnings.append( { "message": _("Mapping column {0} to field {1}").format( - frappe.bold(header_title or "Untitled Column"), frappe.bold(df.label) + frappe.bold(escape_html(header_title) or "Untitled Column"), + frappe.bold(df.label), ), "type": "info", } @@ -953,7 +954,9 @@ def parse(self): self.warnings.append( { "col": column_number, - "message": _("Skipping Duplicate Column {0}").format(frappe.bold(header_title)), + "message": _("Skipping Duplicate Column {0}").format( + frappe.bold(escape_html(header_title)) + ), "type": "info", } ) @@ -964,7 +967,7 @@ def parse(self): self.warnings.append( { "col": column_number, - "message": _("Skipping column {0}").format(frappe.bold(header_title)), + "message": _("Skipping column {0}").format(frappe.bold(escape_html(header_title))), "type": "info", } ) @@ -972,7 +975,9 @@ def parse(self): self.warnings.append( { "col": column_number, - "message": _("Cannot match column {0} with any field").format(frappe.bold(header_title)), + "message": _("Cannot match column {0} with any field").format( + frappe.bold(escape_html(header_title)) + ), "type": "info", } ) @@ -1017,7 +1022,7 @@ def guess_date_format(d): { "col": self.column_number, "message": message.format( - frappe.bold(self.header_title), + frappe.bold(escape_html(self.header_title)), len(unique_date_formats), frappe.bold(user_date_format), ), @@ -1040,13 +1045,14 @@ def validate_values(self): if self.df.fieldtype == "Link": # find all values that dont exist transform = (lambda v: cstr(v).lower()) if frappe.db.db_type == "mariadb" else cstr - values = list({transform(v) for v in self.column_values if v}) + original_values = {transform(v): cstr(v) for v in self.column_values if v} + values = list(original_values.keys()) exists = [ transform(d.name) for d in frappe.get_all(self.df.options, filters={"name": ("in", values)}) ] not_exists = list(set(values) - set(exists)) if not_exists: - missing_values = ", ".join(escape_html(v) for v in not_exists) + missing_values = ", ".join(escape_html(original_values[v]) for v in not_exists) message = _("The following values do not exist for {0}: {1}") self.warnings.append( { diff --git a/frappe/core/doctype/deleted_document/deleted_document.py b/frappe/core/doctype/deleted_document/deleted_document.py index ef4578f9c975..15dc267ccbc8 100644 --- a/frappe/core/doctype/deleted_document/deleted_document.py +++ b/frappe/core/doctype/deleted_document/deleted_document.py @@ -38,13 +38,28 @@ def clear_old_logs(days=180): @frappe.whitelist() -def restore(name, alert=True): +def restore(name: str | int, alert: bool = True): + frappe.only_for("System Manager") deleted = frappe.get_doc("Deleted Document", name) if deleted.restored: frappe.throw(_("Document {0} Already Restored").format(name), exc=frappe.DocumentAlreadyRestored) doc = frappe.get_doc(json.loads(deleted.data)) + + if not frappe.has_permission(doc.doctype, "create"): + frappe.throw( + _("You do not have permission to create or restore documents of type {0}.").format(doc.doctype), + frappe.PermissionError, + ) + + if not frappe.has_permission(doc.doctype, "read", doc=doc): + frappe.throw(_("You do not have permission to restore this document."), frappe.PermissionError) + + original_owner = doc.get("owner") + original_creation = doc.get("creation") + original_modified = doc.get("modified") + original_modified_by = doc.get("modified_by") doc.flags.from_restore = True try: doc.insert() @@ -58,6 +73,19 @@ def restore(name, alert=True): doc.set(workflow_state_fieldname, None) doc.insert() + # retain original metadata + frappe.db.set_value( + doc.doctype, + doc.name, + { + "owner": original_owner, + "creation": original_creation, + "modified": original_modified, + "modified_by": original_modified_by, + }, + update_modified=False, + ) + doc.add_comment("Edit", _("restored {0} as {1}").format(deleted.deleted_name, doc.name)) deleted.new_name = doc.name @@ -69,7 +97,8 @@ def restore(name, alert=True): @frappe.whitelist() -def bulk_restore(docnames): +def bulk_restore(docnames: str | list[str]): + frappe.only_for("System Manager") docnames = frappe.parse_json(docnames) message = _("Restoring Deleted Document") restored, invalid, failed = [], [], [] diff --git a/frappe/core/doctype/deleted_document/test_deleted_document.py b/frappe/core/doctype/deleted_document/test_deleted_document.py index 522d6aa996a5..102166aac8fd 100644 --- a/frappe/core/doctype/deleted_document/test_deleted_document.py +++ b/frappe/core/doctype/deleted_document/test_deleted_document.py @@ -1,7 +1,30 @@ # Copyright (c) 2015, Frappe Technologies and Contributors # License: MIT. See LICENSE +import frappe +from frappe.core.doctype.deleted_document.deleted_document import restore from frappe.tests import IntegrationTestCase class TestDeletedDocument(IntegrationTestCase): - pass + def test_metadata_retention(self): + frappe.set_user("Administrator") + doc = frappe.get_doc({"doctype": "Note", "title": "Test Note", "content": "Test Content"}).insert() + orig_owner = doc.owner + orig_creation = doc.creation + orig_modified = doc.modified + orig_modified_by = doc.modified_by + + frappe.delete_doc("Note", doc.name, force=True) + self.assertFalse(frappe.db.exists("Note", doc.name)) + + log_name = frappe.db.get_value("Deleted Document", {"deleted_name": doc.name, "restored": 0}) + restore(log_name, alert=False) + + new_restored_name = frappe.db.get_value("Deleted Document", log_name, "new_name") + + restored_doc = frappe.get_doc("Note", new_restored_name) + + self.assertEqual(restored_doc.owner, orig_owner) + self.assertEqual(str(restored_doc.creation), str(orig_creation)) + self.assertEqual(str(restored_doc.modified), str(orig_modified)) + self.assertEqual(restored_doc.modified_by, orig_modified_by) diff --git a/frappe/core/doctype/doctype/doctype.json b/frappe/core/doctype/doctype/doctype.json index f77190d18d9f..7f6b9a01432c 100644 --- a/frappe/core/doctype/doctype/doctype.json +++ b/frappe/core/doctype/doctype/doctype.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_bulk_edit": 1, "allow_rename": 1, "autoname": "Prompt", "creation": "2013-02-18 13:36:19", @@ -34,6 +35,7 @@ "quick_entry", "grid_page_length", "rows_threshold_for_grid_search", + "allow_bulk_edit", "cb01", "track_changes", "track_seen", @@ -715,6 +717,14 @@ "fieldname": "recipient_account_field", "fieldtype": "Data", "label": "Recipient Account Field" + }, + { + "default": "1", + "depends_on": "istable", + "description": "Enable bulk update of this field across child table rows.", + "fieldname": "allow_bulk_edit", + "fieldtype": "Check", + "label": "Allow Bulk Edit" } ], "grid_page_length": 50, @@ -824,7 +834,6 @@ ], "route": "doctype", "row_format": "Dynamic", - "rows_threshold_for_grid_search": 20, "search_fields": "module", "show_name_in_global_search": 1, "sort_field": "creation", diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index c4643524c087..b8f821d7f3e3 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -101,6 +101,7 @@ class DocType(Document): actions: DF.Table[DocTypeAction] allow_auto_repeat: DF.Check + allow_bulk_edit: DF.Check allow_copy: DF.Check allow_events_in_timeline: DF.Check allow_guest_to_view: DF.Check @@ -1763,7 +1764,6 @@ def check_decimal_config(docfield): def get_fields_not_allowed_in_list_view(meta) -> list[str]: not_allowed_in_list_view = list(copy.copy(no_value_fields)) - not_allowed_in_list_view.append("Attach Image") if meta.istable: not_allowed_in_list_view.remove("Button") not_allowed_in_list_view.remove("HTML") diff --git a/frappe/core/doctype/doctype/test_doctype.py b/frappe/core/doctype/doctype/test_doctype.py index 28ba53275f2d..1cf1ec85a4c1 100644 --- a/frappe/core/doctype/doctype/test_doctype.py +++ b/frappe/core/doctype/doctype/test_doctype.py @@ -763,10 +763,9 @@ def test_not_in_list_view_for_not_allowed_mandatory_field(self): doctype = new_doctype( fields=[ { - "fieldname": "cover_image", - "fieldtype": "Attach Image", - "label": "Cover Image", - "reqd": 1, # mandatory + "fieldname": "btn", + "fieldtype": "Button", + "label": "Btn", }, { "fieldname": "book_name", diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index 4fe1e5b869d3..f6b0b2313963 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -302,8 +302,8 @@ def validate_file_url(self): if self.is_remote_file or not self.file_url: return - if not self.file_url.startswith(("/files/", "/private/files/", "/api/method/")): - # Probably an invalid URL since it doesn't start with http and isn't an internal URL either + if not self.file_url.startswith(("/files/", "/private/files/")): + # Probably an invalid URL since it doesn't start with http either frappe.throw( _("URL must start with http:// or https://"), title=_("Invalid URL"), diff --git a/frappe/core/doctype/file/test_file.py b/frappe/core/doctype/file/test_file.py index 7f2b6c1703b1..4afc1586999f 100644 --- a/frappe/core/doctype/file/test_file.py +++ b/frappe/core/doctype/file/test_file.py @@ -642,6 +642,110 @@ def test_symlinked_files_folder(self): file.save().reload() self.assertIn("42", file.get_content()) + @IntegrationTestCase.change_settings( + "System Settings", {"allow_guests_to_upload_files": 1, "allowed_doctypes_for_guest_uploads": "ToDo"} + ) + def test_guest_upload_to_non_allowed_doctype(self): + """Verify Guest cannot upload to a restricted DocType.""" + from werkzeug.test import EnvironBuilder + from werkzeug.wrappers import Request + + from frappe.handler import upload_file + + builder = EnvironBuilder(path="/", base_url="http://localhost") + frappe.local.request = Request(builder.get_environ()) + + frappe.set_user("Guest") + frappe.form_dict.doctype = "User" + frappe.form_dict.docname = "Administrator" + + try: + self.assertRaises(frappe.PermissionError, upload_file) + finally: + frappe.set_user("Administrator") + frappe.form_dict.pop("doctype", None) + frappe.form_dict.pop("docname", None) + if hasattr(frappe.local, "request"): + del frappe.local.request + + @IntegrationTestCase.change_settings( + "System Settings", + {"allow_guests_to_upload_files": 1, "allowed_doctypes_for_guest_uploads": "User\nToDo"}, + ) + def test_guest_upload_to_allowed_doctype(self): + """Verify Guest can upload to an explicitly whitelisted DocType.""" + from werkzeug.test import EnvironBuilder + from werkzeug.wrappers import Request + + from frappe.handler import upload_file + + builder = EnvironBuilder(path="/", base_url="http://localhost") + frappe.local.request = Request(builder.get_environ()) + + frappe.set_user("Administrator") + todo = frappe.get_doc({"doctype": "ToDo", "description": "Test Target"}).insert() + + frappe.set_user("Guest") + frappe.form_dict.doctype = "ToDo" + frappe.form_dict.docname = todo.name + frappe.form_dict.file_url = "https://frappe.io/assets/img/logo.png" + frappe.form_dict.file_name = "guest_logo.png" + + file_doc = None + try: + file_doc = upload_file() + self.assertEqual(file_doc.attached_to_name, todo.name) + finally: + frappe.set_user("Administrator") + + if file_doc: + file_doc.delete() + todo.delete() + + frappe.form_dict.pop("doctype", None) + frappe.form_dict.pop("docname", None) + frappe.form_dict.pop("file_url", None) + frappe.form_dict.pop("file_name", None) + + if hasattr(frappe.local, "request"): + del frappe.local.request + + @IntegrationTestCase.change_settings( + "System Settings", {"allow_guests_to_upload_files": 1, "allowed_doctypes_for_guest_uploads": ""} + ) + def test_guest_upload_for_empty_whitelist(self): + """Verify Guest can upload anywhere if the configuration whitelist string is left completely empty.""" + from werkzeug.test import EnvironBuilder + from werkzeug.wrappers import Request + + from frappe.handler import upload_file + + builder = EnvironBuilder(path="/", base_url="http://localhost") + frappe.local.request = Request(builder.get_environ()) + + frappe.set_user("Guest") + frappe.form_dict.doctype = "User" + frappe.form_dict.docname = "Administrator" + frappe.form_dict.file_url = "https://frappe.io/assets/img/logo.png" + frappe.form_dict.file_name = "guest_fallback.png" + + file_doc = None + try: + file_doc = upload_file() + self.assertEqual(file_doc.attached_to_name, "Administrator") + finally: + frappe.set_user("Administrator") + if file_doc: + file_doc.delete() + + frappe.form_dict.pop("doctype", None) + frappe.form_dict.pop("docname", None) + frappe.form_dict.pop("file_url", None) + frappe.form_dict.pop("file_name", None) + + if hasattr(frappe.local, "request"): + del frappe.local.request + @contextmanager def convert_to_symlink(directory): diff --git a/frappe/core/doctype/file/utils.py b/frappe/core/doctype/file/utils.py index a4952e710c7f..3f038c3df45f 100644 --- a/frappe/core/doctype/file/utils.py +++ b/frappe/core/doctype/file/utils.py @@ -463,3 +463,20 @@ def find_file_by_url(path: str, name: str | None = None) -> "File" | None: file: File = frappe.get_doc(doctype="File", **file_data) if file.is_downloadable(): return file + + +def get_safe_file_name(file_name: str) -> str: + return re.sub(r"[/\\%?#]", "_", file_name) + + +def check_path_safety(base_path: str, requested_path: str) -> bool: + """Util to check path safety by ensuring sandboxing and logging unsuccessful attempts""" + base_path = os.path.realpath(base_path) + requested_path = os.path.realpath(requested_path) + if os.path.commonpath([base_path, requested_path]) != base_path: + frappe.log_error( + title="Attempted Unauthorized File Access", + message=f"Blocked access to: {requested_path}", + ) + return False + return True diff --git a/frappe/core/doctype/module_def/module_def.json b/frappe/core/doctype/module_def/module_def.json index 00b6852cd5b0..5609c11da794 100644 --- a/frappe/core/doctype/module_def/module_def.json +++ b/frappe/core/doctype/module_def/module_def.json @@ -139,7 +139,7 @@ "link_fieldname": "module" } ], - "modified": "2024-09-18 12:39:19.512528", + "modified": "2026-05-28", "modified_by": "Administrator", "module": "Core", "name": "Module Def", @@ -169,9 +169,7 @@ }, { "read": 1, - "report": 1, - "role": "All", - "select": 1 + "role": "All" } ], "show_name_in_global_search": 1, diff --git a/frappe/core/doctype/page/page.py b/frappe/core/doctype/page/page.py index 23dea8ecc67a..f7bef48732d3 100644 --- a/frappe/core/doctype/page/page.py +++ b/frappe/core/doctype/page/page.py @@ -110,7 +110,8 @@ def on_trash(self): frappe.throw(_("Deletion of this document is only permitted in developer mode.")) delete_custom_role("page", self.name) - frappe.db.after_commit(self.delete_folder_with_contents) + if frappe.conf.developer_mode: + frappe.db.after_commit(self.delete_folder_with_contents) def delete_folder_with_contents(self): try: diff --git a/frappe/core/doctype/permission_inspector/permission_inspector.json b/frappe/core/doctype/permission_inspector/permission_inspector.json index 690f3b61a2d5..643ec45d20f0 100644 --- a/frappe/core/doctype/permission_inspector/permission_inspector.json +++ b/frappe/core/doctype/permission_inspector/permission_inspector.json @@ -1,7 +1,6 @@ { "actions": [], "allow_rename": 1, - "beta": 1, "creation": "2024-01-03 17:43:27.257317", "doctype": "DocType", "engine": "InnoDB", @@ -72,7 +71,7 @@ "is_virtual": 1, "issingle": 1, "links": [], - "modified": "2024-03-23 16:03:34.140177", + "modified": "2026-05-30 23:16:00.919759", "modified_by": "Administrator", "module": "Core", "name": "Permission Inspector", @@ -84,7 +83,8 @@ "write": 1 } ], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [] -} \ No newline at end of file +} diff --git a/frappe/core/doctype/report/report.json b/frappe/core/doctype/report/report.json index 519184d57ba7..bb217dc7b96a 100644 --- a/frappe/core/doctype/report/report.json +++ b/frappe/core/doctype/report/report.json @@ -17,6 +17,7 @@ "add_total_row", "disabled", "prepared_report", + "disable_prepared_report_automation", "add_translate_data", "timeout", "filters_section", @@ -202,12 +203,20 @@ "fieldname": "add_translate_data", "fieldtype": "Check", "label": "Add Translate Data" + }, + { + "default": "0", + "depends_on": "eval:doc.report_type === \"Script Report\"", + "description": "If checked, Prepared Report will not be enabled automatically on slow runs.", + "fieldname": "disable_prepared_report_automation", + "fieldtype": "Check", + "label": "Disable Prepared Report Automation" } ], "idx": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2025-08-28 18:28:32.510719", + "modified": "2026-06-04", "modified_by": "Administrator", "module": "Core", "name": "Report", @@ -248,10 +257,7 @@ "write": 1 }, { - "email": 1, - "print": 1, "read": 1, - "report": 1, "role": "Desk User" } ], diff --git a/frappe/core/doctype/report/report.py b/frappe/core/doctype/report/report.py index cbc3f2ece9ea..088603369505 100644 --- a/frappe/core/doctype/report/report.py +++ b/frappe/core/doctype/report/report.py @@ -33,6 +33,7 @@ class Report(Document): add_total_row: DF.Check add_translate_data: DF.Check columns: DF.Table[ReportColumn] + disable_prepared_report_automation: DF.Check disabled: DF.Check filters: DF.Table[ReportFilter] is_standard: DF.Literal["No", "Yes"] @@ -184,7 +185,7 @@ def execute_script_report(self, filters): start_time = datetime.datetime.now() prepared_report_watcher = None - if not self.prepared_report: + if not self.prepared_report and not self.disable_prepared_report_automation: prepared_report_watcher = threading.Timer( interval=threshold, function=enable_prepared_report, @@ -370,28 +371,39 @@ def get_standard_report_order_by(self, params): return order_by, group_by, group_by_args def build_standard_report_columns(self, columns, group_by_args): - _columns = [] + from frappe.model.meta import get_default_df + + report_columns = [] for fieldname, doctype in columns: meta = frappe.get_meta(doctype) - if meta.get_field(fieldname): - field = meta.get_field(fieldname) + if meta_df := meta.get_field(fieldname): + column = meta_df.as_dict() + elif default_df := get_default_df(fieldname): + column = default_df.copy() + + if not column.get("label"): + column.label = meta.get_label(fieldname) else: - if fieldname == "_aggregate_column": - label = get_group_by_column_label(group_by_args, meta) - else: - label = meta.get_label(fieldname) + label = ( + get_group_by_column_label(group_by_args, meta) + if fieldname == "_aggregate_column" + else meta.get_label(fieldname) + ) - field = frappe._dict(fieldname=fieldname, label=label) + column = frappe._dict( + { + "fieldname": fieldname, + "label": label, + "fieldtype": "Link" if fieldname == "name" else "Data", + "options": doctype if fieldname == "name" else None, + } + ) - # since name is the primary key for a document, it will always be a Link datatype - if fieldname == "name": - field.fieldtype = "Link" - field.options = doctype + report_columns.append(column) - _columns.append(field) - return _columns + return report_columns def build_data_dict(self, result, columns): data = [] diff --git a/frappe/core/doctype/report/test_report.py b/frappe/core/doctype/report/test_report.py index 6a9ff688095d..1ed50d0c0492 100644 --- a/frappe/core/doctype/report/test_report.py +++ b/frappe/core/doctype/report/test_report.py @@ -407,36 +407,6 @@ def test_add_total_row_for_tree_reports(self): self.assertEqual(result[-1][1], 200) self.assertEqual(result[-1][2], 150.50) - def test_cte_in_query_report(self): - cte_query = textwrap.dedent( - """ - with enabled_users as ( - select name - from `tabUser` - where enabled = 1 - ) - select * from enabled_users; - """ - ) - - report = frappe.get_doc( - { - "doctype": "Report", - "ref_doctype": "User", - "report_name": "Enabled Users List", - "report_type": "Query Report", - "is_standard": "No", - "query": cte_query, - } - ).insert() - - if frappe.db.db_type == "mariadb": - col, rows = report.execute_query_report(filters={}) - self.assertEqual(col[0], "name") - self.assertGreaterEqual(len(rows), 1) - elif frappe.db.db_type == "postgres": - self.assertRaises(frappe.PermissionError, report.execute_query_report, filters={}) - def test_report_cache_invalidation(self): import frappe.sessions from frappe.utils import set_request diff --git a/frappe/core/doctype/sms_settings/sms_settings.py b/frappe/core/doctype/sms_settings/sms_settings.py index 6d9207db8870..fa948316d472 100644 --- a/frappe/core/doctype/sms_settings/sms_settings.py +++ b/frappe/core/doctype/sms_settings/sms_settings.py @@ -151,6 +151,10 @@ def send_request(gateway_url, params, headers=None, use_post=False, use_json=Fal # Create SMS Log # ========================================================= def create_sms_log(args, sent_to): + # SMS Log doctype was removed; skip silently if it isn't available + # (apps that still ship it will continue to log). + if not frappe.db.exists("DocType", "SMS Log"): + return sl = frappe.new_doc("SMS Log") sl.sent_on = nowdate() sl.message = args["message"].decode("utf-8") diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json index d2a1b501503d..535a0a8eb5da 100644 --- a/frappe/core/doctype/system_settings/system_settings.json +++ b/frappe/core/doctype/system_settings/system_settings.json @@ -89,6 +89,7 @@ "delete_background_exported_reports_after", "column_break_uqma", "allowed_file_extensions", + "allowed_doctypes_for_guest_uploads", "app_tab", "default_app", "updates_tab", @@ -797,12 +798,19 @@ "fieldname": "allow_clearing_link_fields", "fieldtype": "Check", "label": "Allow Clearing Link Fields" + }, + { + "depends_on": "eval:doc.allow_guests_to_upload_files == 1", + "description": "Provide a list of allowed Doctypes for file uploads. Each line should contain one allowed Doctype. If unset, uploads to all doctypes are allowed. Example:
User
Item
Quotation", + "fieldname": "allowed_doctypes_for_guest_uploads", + "fieldtype": "Small Text", + "label": "Allowed Doctypes for Guest Uploads" } ], "icon": "fa fa-cog", "issingle": 1, "links": [], - "modified": "2026-04-14 16:26:19.634212", + "modified": "2026-05-21 10:32:53.594355", "modified_by": "Administrator", "module": "Core", "name": "System Settings", diff --git a/frappe/core/doctype/system_settings/system_settings.py b/frappe/core/doctype/system_settings/system_settings.py index 2f26dad6d2bb..436a3c984e34 100644 --- a/frappe/core/doctype/system_settings/system_settings.py +++ b/frappe/core/doctype/system_settings/system_settings.py @@ -25,6 +25,7 @@ class SystemSettings(Document): allow_login_after_fail: DF.Int allow_login_using_mobile_number: DF.Check allow_login_using_user_name: DF.Check + allowed_doctypes_for_guest_uploads: DF.SmallText | None allowed_file_extensions: DF.SmallText | None app_name: DF.Data | None apply_strict_user_permissions: DF.Check diff --git a/frappe/core/doctype/user/user.js b/frappe/core/doctype/user/user.js index 7e40e3ccd46e..2074c91d1dee 100644 --- a/frappe/core/doctype/user/user.js +++ b/frappe/core/doctype/user/user.js @@ -224,7 +224,7 @@ frappe.ui.form.on("User", { if ( cint(frappe.boot.sysdefaults.enable_two_factor_auth) && - (frappe.session.user == doc.name || frappe.user.has_role("System Manager")) + frappe.user.has_role("System Manager") ) { frm.add_custom_button( __("Reset OTP Secret"), @@ -374,8 +374,8 @@ frappe.ui.form.on("User", { }, setup_impersonation: function (frm) { if ( - frappe.session.user === "Administrator" && - frm.doc.name != "Administrator" && + (frappe.session.user === "Administrator" || frm.has_perm("impersonate")) && + frm.doc.name !== frappe.session.user && !frm.is_new() ) { frm.add_custom_button(__("Impersonate"), () => { diff --git a/frappe/core/doctype/user/user.json b/frappe/core/doctype/user/user.json index f358a30a4394..cc0a8d147ffa 100644 --- a/frappe/core/doctype/user/user.json +++ b/frappe/core/doctype/user/user.json @@ -34,6 +34,8 @@ "modules_html", "block_modules", "home_settings", + "allowed_accounts_section", + "allowed_accounts", "short_bio", "gender", "birth_date", @@ -148,6 +150,7 @@ "label": "First Name", "oldfieldname": "first_name", "oldfieldtype": "Data", + "permlevel": 1, "reqd": 1 }, { @@ -155,7 +158,8 @@ "fieldtype": "Data", "label": "Middle Name", "oldfieldname": "middle_name", - "oldfieldtype": "Data" + "oldfieldtype": "Data", + "permlevel": 1 }, { "bold": 1, @@ -163,7 +167,8 @@ "fieldtype": "Data", "label": "Last Name", "oldfieldname": "last_name", - "oldfieldtype": "Data" + "oldfieldtype": "Data", + "permlevel": 1 }, { "fieldname": "full_name", @@ -179,7 +184,8 @@ "depends_on": "eval:doc.__islocal", "fieldname": "send_welcome_email", "fieldtype": "Check", - "label": "Send Welcome Email" + "label": "Send Welcome Email", + "permlevel": 1 }, { "default": "0", @@ -202,6 +208,7 @@ "in_global_search": 1, "in_standard_filter": 1, "label": "Username", + "permlevel": 1, "unique": 1 }, { @@ -269,19 +276,22 @@ "label": "Gender", "oldfieldname": "gender", "oldfieldtype": "Select", - "options": "Gender" + "options": "Gender", + "permlevel": 1 }, { "fieldname": "phone", "fieldtype": "Data", "label": "Phone", - "options": "Phone" + "options": "Phone", + "permlevel": 1 }, { "fieldname": "mobile_no", "fieldtype": "Data", "label": "Mobile No", "options": "Phone", + "permlevel": 1, "unique": 1 }, { @@ -290,13 +300,15 @@ "label": "Birth Date", "no_copy": 1, "oldfieldname": "birth_date", - "oldfieldtype": "Date" + "oldfieldtype": "Date", + "permlevel": 1 }, { "fieldname": "location", "fieldtype": "Data", "label": "Location", - "no_copy": 1 + "no_copy": 1, + "permlevel": 1 }, { "fieldname": "banner_image", @@ -310,13 +322,15 @@ { "fieldname": "interest", "fieldtype": "Small Text", - "label": "Interests" + "label": "Interests", + "permlevel": 1 }, { "fieldname": "bio", "fieldtype": "Small Text", "label": "Bio", - "no_copy": 1 + "no_copy": 1, + "permlevel": 1 }, { "default": "0", @@ -335,13 +349,15 @@ "fieldname": "new_password", "fieldtype": "Password", "label": "Set New Password", - "no_copy": 1 + "no_copy": 1, + "permlevel": 1 }, { "default": "1", "fieldname": "logout_all_sessions", "fieldtype": "Check", - "label": "Logout From All Devices After Changing Password" + "label": "Logout From All Devices After Changing Password", + "permlevel": 1 }, { "fieldname": "reset_password_key", @@ -378,7 +394,8 @@ "default": "0", "fieldname": "document_follow_notify", "fieldtype": "Check", - "label": "Send Notifications For Documents Followed By Me" + "label": "Send Notifications For Documents Followed By Me", + "permlevel": 1 }, { "default": "Daily", @@ -386,7 +403,8 @@ "fieldname": "document_follow_frequency", "fieldtype": "Select", "label": "Frequency", - "options": "Hourly\nDaily\nWeekly" + "options": "Hourly\nDaily\nWeekly", + "permlevel": 1 }, { "collapsible": 1, @@ -399,25 +417,29 @@ "default": "1", "fieldname": "thread_notify", "fieldtype": "Check", - "label": "Send Notifications For Email Threads" + "label": "Send Notifications For Email Threads", + "permlevel": 1 }, { "default": "0", "fieldname": "send_me_a_copy", "fieldtype": "Check", - "label": "Send Me A Copy of Outgoing Emails" + "label": "Send Me A Copy of Outgoing Emails", + "permlevel": 1 }, { "default": "1", "fieldname": "allowed_in_mentions", "fieldtype": "Check", - "label": "Allowed In Mentions" + "label": "Allowed In Mentions", + "permlevel": 1 }, { "fieldname": "email_signature", "fieldtype": "Text Editor", "label": "Email Signature", - "no_copy": 1 + "no_copy": 1, + "permlevel": 1 }, { "fieldname": "user_emails", @@ -487,7 +509,8 @@ "default": "2", "fieldname": "simultaneous_sessions", "fieldtype": "Int", - "label": "Simultaneous Sessions" + "label": "Simultaneous Sessions", + "permlevel": 1 }, { "bold": 1, @@ -530,7 +553,8 @@ "description": "If enabled, user can login from any IP Address using Two Factor Auth, this can also be set for all users in System Settings", "fieldname": "bypass_restrict_ip_check_if_2fa_enabled", "fieldtype": "Check", - "label": "Bypass Restricted IP Address Check If Two Factor Auth Enabled" + "label": "Bypass Restricted IP Address Check If Two Factor Auth Enabled", + "permlevel": 1 }, { "fieldname": "column_break1", @@ -585,7 +609,8 @@ "fieldname": "social_logins", "fieldtype": "Table", "label": "Social Logins", - "options": "User Social Login" + "options": "User Social Login", + "permlevel": 1 }, { "collapsible": 1, @@ -604,7 +629,6 @@ "unique": 1 }, { - "description": "\n Click here to learn about token-based authentication\n", "fieldname": "generate_keys", "fieldtype": "Button", "label": "Generate Keys", @@ -643,7 +667,8 @@ "fieldname": "module_profile", "fieldtype": "Link", "label": "Module Profile", - "options": "Module Profile" + "options": "Module Profile", + "permlevel": 1 }, { "description": "Stores the datetime when the last reset password key was generated.", @@ -663,35 +688,40 @@ "depends_on": "eval:(doc.document_follow_notify== 1)", "fieldname": "follow_created_documents", "fieldtype": "Check", - "label": "Auto follow documents that you create" + "label": "Auto follow documents that you create", + "permlevel": 1 }, { "default": "0", "depends_on": "eval:(doc.document_follow_notify== 1)", "fieldname": "follow_commented_documents", "fieldtype": "Check", - "label": "Auto follow documents that you comment on" + "label": "Auto follow documents that you comment on", + "permlevel": 1 }, { "default": "0", "depends_on": "eval:(doc.document_follow_notify== 1)", "fieldname": "follow_liked_documents", "fieldtype": "Check", - "label": "Auto follow documents that you Like" + "label": "Auto follow documents that you Like", + "permlevel": 1 }, { "default": "0", "depends_on": "eval:(doc.document_follow_notify== 1)", "fieldname": "follow_shared_documents", "fieldtype": "Check", - "label": "Auto follow documents that are shared with you" + "label": "Auto follow documents that are shared with you", + "permlevel": 1 }, { "default": "0", "depends_on": "eval:(doc.document_follow_notify== 1)", "fieldname": "follow_assigned_documents", "fieldtype": "Check", - "label": "Auto follow documents that are assigned to you" + "label": "Auto follow documents that are assigned to you", + "permlevel": 1 }, { "fieldname": "user_details_tab", @@ -740,7 +770,8 @@ "fieldname": "default_workspace", "fieldtype": "Link", "label": "Default Workspace", - "options": "Workspace" + "options": "Workspace", + "permlevel": 1 }, { "collapsible": 1, @@ -765,7 +796,8 @@ "description": "Redirect to the selected app after login", "fieldname": "default_app", "fieldtype": "Select", - "label": "Default App" + "label": "Default App", + "permlevel": 1 }, { "collapsible": 1, @@ -777,13 +809,15 @@ "default": "1", "fieldname": "search_bar", "fieldtype": "Check", - "label": "Search Bar" + "label": "Search Bar", + "permlevel": 1 }, { "default": "1", "fieldname": "notifications", "fieldtype": "Check", - "label": "Notifications" + "label": "Notifications", + "permlevel": 1 }, { "collapsible": 1, @@ -795,19 +829,22 @@ "default": "1", "fieldname": "list_sidebar", "fieldtype": "Check", - "label": "Sidebar" + "label": "Sidebar", + "permlevel": 1 }, { "default": "1", "fieldname": "bulk_actions", "fieldtype": "Check", - "label": "Bulk Actions" + "label": "Bulk Actions", + "permlevel": 1 }, { "default": "1", "fieldname": "view_switcher", "fieldtype": "Check", - "label": "View Switcher" + "label": "View Switcher", + "permlevel": 1 }, { "collapsible": 1, @@ -819,19 +856,22 @@ "default": "1", "fieldname": "form_sidebar", "fieldtype": "Check", - "label": "Sidebar" + "label": "Sidebar", + "permlevel": 1 }, { "default": "1", "fieldname": "timeline", "fieldtype": "Check", - "label": "Timeline" + "label": "Timeline", + "permlevel": 1 }, { "default": "1", "fieldname": "dashboard", "fieldtype": "Check", - "label": "Dashboard" + "label": "Dashboard", + "permlevel": 1 }, { "default": "0", @@ -857,6 +897,17 @@ "fieldname": "form_navigation_buttons", "fieldtype": "Check", "label": "Navigation Buttons" + }, + { + "fieldname": "allowed_accounts_section", + "fieldtype": "Section Break", + "label": "Allowed Accounts" + }, + { + "fieldname": "allowed_accounts", + "fieldtype": "Table", + "options": "User Account", + "permlevel": 1 } ], "icon": "fa fa-user", @@ -910,7 +961,7 @@ } ], "make_attachments_public": 1, - "modified": "2026-04-28 21:59:59.160099", + "modified": "2026-05-28", "modified_by": "Administrator", "module": "Core", "name": "User", @@ -938,6 +989,11 @@ { "role": "Desk User", "select": 1 + }, + { + "permlevel": 1, + "read": 1, + "role": "Desk User" } ], "quick_entry": 1, diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 5349e64c8fc1..db9374424df1 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -65,6 +65,7 @@ class User(Document): from frappe.core.doctype.block_module.block_module import BlockModule from frappe.core.doctype.defaultvalue.defaultvalue import DefaultValue from frappe.core.doctype.has_role.has_role import HasRole + from frappe.core.doctype.user_account.user_account import UserAccount from frappe.core.doctype.user_email.user_email import UserEmail from frappe.core.doctype.user_role_profile.user_role_profile import UserRoleProfile from frappe.core.doctype.user_session_display.user_session_display import UserSessionDisplay @@ -72,6 +73,7 @@ class User(Document): from frappe.types import DF active_sessions: DF.Table[UserSessionDisplay] + allowed_accounts: DF.Table[UserAccount] allowed_in_mentions: DF.Check api_key: DF.Data | None api_secret: DF.Password | None diff --git a/frappe/core/doctype/user_account/__init__.py b/frappe/core/doctype/user_account/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/frappe/core/doctype/user_account/user_account.json b/frappe/core/doctype/user_account/user_account.json new file mode 100644 index 000000000000..677fd4b4e8ca --- /dev/null +++ b/frappe/core/doctype/user_account/user_account.json @@ -0,0 +1,46 @@ +{ + "actions": [], + "creation": "2025-10-14", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": ["access_type", "account", "mode_of_payment"], + "fields": [ + { + "fieldname": "access_type", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Access Type", + "options": "Payment\nRead", + "reqd": 1 + }, + { + "fieldname": "account", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Account", + "options": "Account", + "reqd": 1 + }, + { + "fieldname": "mode_of_payment", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Default Mode of Payment", + "options": "Mode of Payment" + } + ], + "grid_page_length": 50, + "istable": 1, + "links": [], + "modified": "2026-05-25", + "modified_by": "Administrator", + "module": "Core", + "name": "User Account", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/frappe/core/doctype/user_account/user_account.py b/frappe/core/doctype/user_account/user_account.py new file mode 100644 index 000000000000..7c693558269b --- /dev/null +++ b/frappe/core/doctype/user_account/user_account.py @@ -0,0 +1,21 @@ +from frappe.model.document import Document + + +class UserAccount(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + access_type: DF.Literal["Payment", "Read"] + account: DF.Link + mode_of_payment: DF.Link | None + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + # end: auto-generated types + + pass diff --git a/frappe/core/doctype/user_group/user_group.json b/frappe/core/doctype/user_group/user_group.json index fd49ac1266f5..9ea8a9ea94b7 100644 --- a/frappe/core/doctype/user_group/user_group.json +++ b/frappe/core/doctype/user_group/user_group.json @@ -19,7 +19,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2024-03-23 16:04:00.597980", + "modified": "2026-05-28", "modified_by": "Administrator", "module": "Core", "name": "User Group", @@ -37,10 +37,6 @@ "role": "System Manager", "share": 1, "write": 1 - }, - { - "read": 1, - "role": "Desk User" } ], "sort_field": "creation", diff --git a/frappe/core/page/dashboard_view/dashboard_view.js b/frappe/core/page/dashboard_view/dashboard_view.js index c9d8342f2c86..2b7e0a53ff79 100644 --- a/frappe/core/page/dashboard_view/dashboard_view.js +++ b/frappe/core/page/dashboard_view/dashboard_view.js @@ -88,10 +88,7 @@ class Dashboard { "frappe.desk.doctype.dashboard.dashboard.get_permitted_charts" ).then((charts) => { if (!charts.length) { - frappe.msgprint( - __("No Permitted Charts on this Dashboard"), - __("No Permitted Charts") - ); + return; } frappe.dashboard_utils.get_dashboard_settings().then((settings) => { diff --git a/frappe/custom/doctype/customize_form/customize_form.json b/frappe/custom/doctype/customize_form/customize_form.json index 5cb6e9139856..f04563a1cbfc 100644 --- a/frappe/custom/doctype/customize_form/customize_form.json +++ b/frappe/custom/doctype/customize_form/customize_form.json @@ -24,6 +24,7 @@ "track_views", "allow_auto_repeat", "allow_import", + "allow_bulk_edit", "queue_in_background", "naming_section", "naming_rule", @@ -222,6 +223,14 @@ "fieldtype": "Check", "label": "Allow Import (via Data Import Tool)" }, + { + "default": "1", + "depends_on": "istable", + "description": "Enable bulk edit for child table fields in Form view.", + "fieldname": "allow_bulk_edit", + "fieldtype": "Check", + "label": "Allow Bulk Edit" + }, { "depends_on": "email_append_to", "fieldname": "subject_field", diff --git a/frappe/custom/doctype/customize_form/customize_form.py b/frappe/custom/doctype/customize_form/customize_form.py index 92b8b34a8b74..07deb438a742 100644 --- a/frappe/custom/doctype/customize_form/customize_form.py +++ b/frappe/custom/doctype/customize_form/customize_form.py @@ -42,6 +42,7 @@ class CustomizeForm(Document): actions: DF.Table[DocTypeAction] allow_auto_repeat: DF.Check + allow_bulk_edit: DF.Check allow_copy: DF.Check allow_import: DF.Check autoname: DF.Data | None @@ -744,6 +745,7 @@ def get_link_filters_from_doc_without_customisations(doctype, fieldname): "track_views": "Check", "allow_auto_repeat": "Check", "allow_import": "Check", + "allow_bulk_edit": "Check", "show_name_in_global_search": "Check", "show_preview_popup": "Check", "default_email_template": "Data", diff --git a/frappe/custom/doctype/doctype_layout/doctype_layout.json b/frappe/custom/doctype/doctype_layout/doctype_layout.json index 131924bc776a..365b1ac8b6a4 100644 --- a/frappe/custom/doctype/doctype_layout/doctype_layout.json +++ b/frappe/custom/doctype/doctype_layout/doctype_layout.json @@ -43,7 +43,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2024-03-23 16:03:22.020755", + "modified": "2026-05-28", "modified_by": "Administrator", "module": "Custom", "name": "DocType Layout", @@ -61,10 +61,6 @@ "role": "System Manager", "share": 1, "write": 1 - }, - { - "read": 1, - "role": "Desk User" } ], "route": "doctype-layout", diff --git a/frappe/database/mariadb/database.py b/frappe/database/mariadb/database.py index c9c57b1c00cc..0c5b2c11a056 100644 --- a/frappe/database/mariadb/database.py +++ b/frappe/database/mariadb/database.py @@ -26,19 +26,19 @@ class MariaDBExceptionUtil: @staticmethod def is_deadlocked(e: pymysql.Error) -> bool: # Snapshot isolation is also treated as deadlock from User POV - return e.args[0] in (ER.LOCK_DEADLOCK, ER.CHECKREAD) + return e.args and e.args[0] in (ER.LOCK_DEADLOCK, ER.CHECKREAD) @staticmethod def is_timedout(e: pymysql.Error) -> bool: - return e.args[0] == ER.LOCK_WAIT_TIMEOUT + return e.args and e.args[0] == ER.LOCK_WAIT_TIMEOUT @staticmethod def is_read_only_mode_error(e: pymysql.Error) -> bool: - return e.args[0] == 1792 + return e.args and e.args[0] == 1792 @staticmethod def is_table_missing(e: pymysql.Error) -> bool: - return e.args[0] == ER.NO_SUCH_TABLE + return e.args and e.args[0] == ER.NO_SUCH_TABLE @staticmethod def is_missing_table(e: pymysql.Error) -> bool: @@ -46,39 +46,39 @@ def is_missing_table(e: pymysql.Error) -> bool: @staticmethod def is_missing_column(e: pymysql.Error) -> bool: - return e.args[0] == ER.BAD_FIELD_ERROR + return e.args and e.args[0] == ER.BAD_FIELD_ERROR @staticmethod def is_duplicate_fieldname(e: pymysql.Error) -> bool: - return e.args[0] == ER.DUP_FIELDNAME + return e.args and e.args[0] == ER.DUP_FIELDNAME @staticmethod def is_duplicate_entry(e: pymysql.Error) -> bool: - return e.args[0] == ER.DUP_ENTRY + return e.args and e.args[0] == ER.DUP_ENTRY @staticmethod def is_access_denied(e: pymysql.Error) -> bool: - return e.args[0] == ER.ACCESS_DENIED_ERROR + return e.args and e.args[0] == ER.ACCESS_DENIED_ERROR @staticmethod def cant_drop_field_or_key(e: pymysql.Error) -> bool: - return e.args[0] == ER.CANT_DROP_FIELD_OR_KEY + return e.args and e.args[0] == ER.CANT_DROP_FIELD_OR_KEY @staticmethod def is_syntax_error(e: pymysql.Error) -> bool: - return e.args[0] == ER.PARSE_ERROR + return e.args and e.args[0] == ER.PARSE_ERROR @staticmethod def is_statement_timeout(e: pymysql.Error) -> bool: - return e.args[0] == 1969 + return e.args and e.args[0] == 1969 @staticmethod def is_data_too_long(e: pymysql.Error) -> bool: - return e.args[0] == ER.DATA_TOO_LONG + return e.args and e.args[0] == ER.DATA_TOO_LONG @staticmethod def is_db_table_size_limit(e: pymysql.Error) -> bool: - return e.args[0] == ER.TOO_BIG_ROWSIZE + return e.args and e.args[0] == ER.TOO_BIG_ROWSIZE @staticmethod def is_primary_key_violation(e: pymysql.Error) -> bool: diff --git a/frappe/database/mariadb/mysqlclient.py b/frappe/database/mariadb/mysqlclient.py index 2ccc5f079d4e..5c38dede77a6 100644 --- a/frappe/database/mariadb/mysqlclient.py +++ b/frappe/database/mariadb/mysqlclient.py @@ -29,19 +29,19 @@ class MariaDBExceptionUtil: @staticmethod def is_deadlocked(e: MySQLdb.Error) -> bool: # Snapshot isolation is also treated as deadlock from User POV - return e.args[0] in (ER.LOCK_DEADLOCK, ER.CHECKREAD) + return e.args and e.args[0] in (ER.LOCK_DEADLOCK, ER.CHECKREAD) @staticmethod def is_timedout(e: MySQLdb.Error) -> bool: - return e.args[0] == ER.LOCK_WAIT_TIMEOUT + return e.args and e.args[0] == ER.LOCK_WAIT_TIMEOUT @staticmethod def is_read_only_mode_error(e: MySQLdb.Error) -> bool: - return e.args[0] == ER.CANT_EXECUTE_IN_READ_ONLY_TRANSACTION + return e.args and e.args[0] == ER.CANT_EXECUTE_IN_READ_ONLY_TRANSACTION @staticmethod def is_table_missing(e: MySQLdb.Error) -> bool: - return e.args[0] == ER.NO_SUCH_TABLE + return e.args and e.args[0] == ER.NO_SUCH_TABLE @staticmethod def is_missing_table(e: MySQLdb.Error) -> bool: @@ -49,39 +49,39 @@ def is_missing_table(e: MySQLdb.Error) -> bool: @staticmethod def is_missing_column(e: MySQLdb.Error) -> bool: - return e.args[0] == ER.BAD_FIELD_ERROR + return e.args and e.args[0] == ER.BAD_FIELD_ERROR @staticmethod def is_duplicate_fieldname(e: MySQLdb.Error) -> bool: - return e.args[0] == ER.DUP_FIELDNAME + return e.args and e.args[0] == ER.DUP_FIELDNAME @staticmethod def is_duplicate_entry(e: MySQLdb.Error) -> bool: - return e.args[0] == ER.DUP_ENTRY + return e.args and e.args[0] == ER.DUP_ENTRY @staticmethod def is_access_denied(e: MySQLdb.Error) -> bool: - return e.args[0] == ER.ACCESS_DENIED_ERROR + return e.args and e.args[0] == ER.ACCESS_DENIED_ERROR @staticmethod def cant_drop_field_or_key(e: MySQLdb.Error) -> bool: - return e.args[0] == ER.CANT_DROP_FIELD_OR_KEY + return e.args and e.args[0] == ER.CANT_DROP_FIELD_OR_KEY @staticmethod def is_syntax_error(e: MySQLdb.Error) -> bool: - return e.args[0] == ER.PARSE_ERROR + return e.args and e.args[0] == ER.PARSE_ERROR @staticmethod def is_statement_timeout(e: MySQLdb.Error) -> bool: - return e.args[0] == ER_STATEMENT_TIMEOUT + return e.args and e.args[0] == ER_STATEMENT_TIMEOUT @staticmethod def is_data_too_long(e: MySQLdb.Error) -> bool: - return e.args[0] == ER.DATA_TOO_LONG + return e.args and e.args[0] == ER.DATA_TOO_LONG @staticmethod def is_db_table_size_limit(e: MySQLdb.Error) -> bool: - return e.args[0] == ER.TOO_BIG_ROWSIZE + return e.args and e.args[0] == ER.TOO_BIG_ROWSIZE @staticmethod def is_primary_key_violation(e: MySQLdb.Error) -> bool: diff --git a/frappe/database/mariadb/schema.py b/frappe/database/mariadb/schema.py index 9039ff3356b7..2b703e8abda8 100644 --- a/frappe/database/mariadb/schema.py +++ b/frappe/database/mariadb/schema.py @@ -1,5 +1,3 @@ -from pymysql.constants.ER import DUP_ENTRY - import frappe from frappe import _ from frappe.database.schema import DbColumn, DBTable @@ -166,7 +164,7 @@ def alter(self): if query := locals().get("query"): # this weirdness is to avoid potentially unbounded vars print(f"Failed to alter schema using query: {query}") - if e.args[0] == DUP_ENTRY: + if frappe.db.is_duplicate_entry(e): fieldname = str(e).split("'")[-2] frappe.throw( _( diff --git a/frappe/database/query.py b/frappe/database/query.py index 3e4a7bad035f..7fbf2548d7ea 100644 --- a/frappe/database/query.py +++ b/frappe/database/query.py @@ -23,7 +23,7 @@ from frappe.model import OPTIONAL_FIELDS, get_permitted_fields from frappe.model.base_document import DOCTYPES_FOR_DOCTYPE from frappe.model.document import Document -from frappe.query_builder import Criterion, Field, Order, functions +from frappe.query_builder import Criterion, CustomFunction, Field, Order, functions from frappe.query_builder.custom import Month, MonthName, Quarter CORE_DOCTYPES = DOCTYPES_FOR_DOCTYPE | frozenset( @@ -193,6 +193,9 @@ def _apply_datetime_field_filter_conversion(between_values: tuple | list, doctyp "MONTHNAME": MonthName, "QUARTER": Quarter, "MONTH": Month, + "DATE": functions.Date, + "VALUEWRAPPER": ValueWrapper, + "TIME": CustomFunction("TIME", ["time"]), } # Functions that accept '*' as an argument (e.g., COUNT(*)) @@ -615,15 +618,17 @@ def _build_criterion_for_simple_filter( # If _field is from a dynamic field, its name might be just the target fieldname. # We need the original string ('link.target') or the fieldname from the main doctype. original_field_name = field if isinstance(field, str) else _field.name - # Check if the original field name exists in the *main* doctype meta - main_meta = frappe.get_meta(self.doctype) - if main_meta.has_field(original_field_name): - _df = main_meta.get_field(original_field_name) - ref_doctype = _df.options if _df else self.doctype + # When the filter targets a child table, resolve the field against + # the child doctype rather than the parent. + lookup_doctype = doctype or self.doctype + lookup_meta = frappe.get_meta(lookup_doctype) + if lookup_meta.has_field(original_field_name): + _df = lookup_meta.get_field(original_field_name) + ref_doctype = _df.options if _df else lookup_doctype else: - # If not in main doctype, assume it's a standard field like 'name' or refers to the main doctype itself + # If not in lookup doctype, assume it's a standard field like 'name' or refers to the lookup doctype itself # This part might need refinement if nested set operators are used with dynamic fields. - ref_doctype = self.doctype + ref_doctype = lookup_doctype nodes = get_nested_set_hierarchy_result(ref_doctype, docname, hierarchy) operator_fn = ( diff --git a/frappe/database/schema.py b/frappe/database/schema.py index d29bff1615da..5922c2823107 100644 --- a/frappe/database/schema.py +++ b/frappe/database/schema.py @@ -146,7 +146,7 @@ def validate(self): # case when the field is no longer a varchar continue current_length = current_length[0] - if cint(current_length) != cint(new_length): + if cint(current_length) > cint(new_length): try: # check for truncation max_length = frappe.db.sql( diff --git a/frappe/database/sequence.py b/frappe/database/sequence.py index f68ff7c0e607..6c515ca15705 100644 --- a/frappe/database/sequence.py +++ b/frappe/database/sequence.py @@ -97,3 +97,50 @@ def set_next_val( "mariadb": f"SELECT SETVAL(`{scrub(doctype_name + slug)}`, {next_val}, {is_val_used})", } ) + + +def _get_existing_sequences() -> set[str]: + if db.db_type == "postgres": + rows = db.sql( + """SELECT sequence_name FROM information_schema.sequences + WHERE sequence_schema = 'public'""" + ) + else: + rows = db.sql( + """SELECT TABLE_NAME FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'SEQUENCE'""" + ) + return {r[0] for r in rows} + + +def create_missing_sequences() -> list[str]: + """Recreate sequences for autoincrement doctypes whose sequence object is missing.""" + import frappe + from frappe.query_builder.functions import Max + + if db.db_type == "sqlite": + return [] + + doctypes = frappe.get_all( + "DocType", + filters={"autoname": "autoincrement", "issingle": 0, "is_virtual": 0}, + pluck="name", + ) + if not doctypes: + return [] + + existing = _get_existing_sequences() + created = [] + + for doctype in doctypes: + if scrub(f"{doctype}_id_seq") in existing: + continue + + # align past existing rows to avoid name collisions; empty tables fall + # back to the default start (1), same as normal sequence creation + table = frappe.qb.DocType(doctype) + max_name = frappe.qb.from_(table).select(Max(table["name"])).run()[0][0] + create_sequence(doctype, check_not_exists=True, start_value=int(max_name) + 1 if max_name else 0) + created.append(doctype) + + return created diff --git a/frappe/desk/desk_views.py b/frappe/desk/desk_views.py new file mode 100644 index 000000000000..67d9286c4581 --- /dev/null +++ b/frappe/desk/desk_views.py @@ -0,0 +1,258 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors +# License: MIT. See LICENSE + +from functools import cached_property + +import frappe +from frappe.permissions import has_permission +from frappe.query_builder import DocType +from frappe.query_builder.functions import Count +from frappe.query_builder.terms import SubQuery +from frappe.utils.data import cstr + + +class DeskViews: + """Builds the desk views (workspaces, dashboards, pages and reports) for the boot payload.""" + + # allowed-entity caches refresh every six hours + CACHE_EXPIRY = 6 * 60 * 60 + + def __init__(self): + self.pages = {} + self.reports = {} + self.workspaces = {} + self.dashboards = [] + + def build_entities(self): + from frappe.desk.desktop import get_workspaces + + self.pages = self.get_allowed_pages() + self.reports = self.get_allowed_reports() + self.workspaces = get_workspaces() + self.dashboards = self.get_allowed_dashboards(cache=True) + return self + + def add_to_boot(self, bootinfo): + bootinfo.page_info = self.pages + bootinfo.allowed_reports = self.reports + bootinfo.workspaces = self.workspaces + bootinfo.dashboards = self.dashboards + + # The properties below are the per-user view-permission data read by `is_item_allowed`. + # They load lazily and cache on the instance, so consumers don't need to populate them. + + @cached_property + def allowed_pages(self): + return self.get_allowed_pages(cache=True) + + @cached_property + def allowed_reports(self): + return self.get_allowed_reports(cache=True) + + @cached_property + def allowed_dashboards(self): + return {d["name"] for d in self.get_allowed_dashboards(cache=True)} + + @cached_property + def restricted_doctypes(self): + from frappe.cache_manager import build_domain_restricted_doctype_cache + + return frappe.cache.get_value("domain_restricted_doctypes") or build_domain_restricted_doctype_cache() + + @cached_property + def restricted_pages(self): + from frappe.cache_manager import build_domain_restricted_page_cache + + return frappe.cache.get_value("domain_restricted_pages") or build_domain_restricted_page_cache() + + def is_item_allowed(self, name, item_type, allowed_workspaces=None): + """Return whether the user may see a sidebar/workspace item. + + Relies on the consumer setting `can_read`, `allowed_pages`, `allowed_reports`, + `allowed_dashboards`, `restricted_doctypes` and `restricted_pages` on the instance. + """ + if frappe.session.user == "Administrator": + return True + + item_type = item_type.lower() + + if item_type == "doctype": + return ( + name in (self.can_read or []) + and name in (self.restricted_doctypes or []) + and frappe.has_permission(name) + ) + if item_type == "page": + return name in self.allowed_pages and name in self.restricted_pages + if item_type == "report": + return not frappe.db.get_value("Report", name, "disabled") and name in self.allowed_reports + if item_type == "dashboard": + return name in (self.allowed_dashboards or []) + if item_type in ("help", "url"): + return True + if item_type == "workspace": + return name in (allowed_workspaces or []) + + return False + + @classmethod + def get_allowed_pages(cls, cache=False, user: str | None = None): + return cls.get_user_pages_or_reports("Page", cache=cache, user=user) + + @classmethod + def get_allowed_reports(cls, cache=False, user: str | None = None): + return cls.get_user_pages_or_reports("Report", cache=cache, user=user) + + @classmethod + def get_allowed_report_names(cls, cache=False, user: str | None = None) -> set[str]: + return {cstr(report) for report in cls.get_allowed_reports(cache=cache, user=user).keys() if report} + + @classmethod + def get_allowed_dashboards(cls, cache=False): + """Return dashboards the user is allowed to see. + + A dashboard is permitted when the user can access at least one of its charts or cards. + Evaluated for the current session user and cached like pages and reports. + """ + from frappe.desk.doctype.dashboard.dashboard import get_permitted_cards, get_permitted_charts + + def build(): + return [ + {"name": name} + for name in frappe.get_all("Dashboard", pluck="name") + if get_permitted_charts(name) or get_permitted_cards(name) + ] + + return cls._allowed_entity_cache("allowed_dashboards", frappe.session.user, build, cache=cache) + + @classmethod + def _allowed_entity_cache(cls, key, user, builder, cache=False): + """Return the user's allowed entities for `key`, rebuilding and re-caching on a miss. + + Pass `cache=True` to return a previously cached value instead of rebuilding. The result + is stored per-user and expires after `CACHE_EXPIRY` seconds. + """ + if cache: + cached = frappe.cache.get_value(key, user=user) + if cached: + return cached + + value = builder() + frappe.cache.set_value(key, value, user, cls.CACHE_EXPIRY) + return value + + @classmethod + def get_user_pages_or_reports(cls, parent, cache=False, user: str | None = None): + if user is None: + user = frappe.session.user + + return cls._allowed_entity_cache( + "has_role:" + parent, + user, + lambda: cls._build_user_pages_or_reports(parent, user), + cache=cache, + ) + + @classmethod + def _build_user_pages_or_reports(cls, parent, user): + roles = frappe.get_roles(user) + has_role = {} + + page = DocType("Page") + report = DocType("Report") + + is_report = parent == "Report" + + if is_report: + columns = (report.name.as_("title"), report.ref_doctype, report.report_type) + else: + columns = (page.title.as_("title"),) + + customRole = DocType("Custom Role") + hasRole = DocType("Has Role") + parentTable = DocType(parent) + + # get pages or reports set on custom role + pages_with_custom_roles = ( + frappe.qb.from_(customRole) + .from_(hasRole) + .from_(parentTable) + .select( + customRole[parent.lower()].as_("name"), customRole.modified, customRole.ref_doctype, *columns + ) + .where( + (hasRole.parent == customRole.name) + & (parentTable.name == customRole[parent.lower()]) + & (customRole[parent.lower()].isnotnull()) + & (hasRole.role.isin(roles)) + ) + ).run(as_dict=True) + + for p in pages_with_custom_roles: + has_role[p.name] = {"modified": p.modified, "title": p.title, "ref_doctype": p.ref_doctype} + + subq = ( + frappe.qb.from_(customRole) + .select(customRole[parent.lower()]) + .where(customRole[parent.lower()].isnotnull()) + ) + + pages_with_standard_roles = ( + frappe.qb.from_(hasRole) + .from_(parentTable) + .select(parentTable.name.as_("name"), parentTable.modified, *columns) + .where( + (hasRole.role.isin(roles)) + & (hasRole.parent == parentTable.name) + & (parentTable.name.notin(subq)) + ) + .distinct() + ) + + if is_report: + pages_with_standard_roles = pages_with_standard_roles.where(report.disabled == 0) + + pages_with_standard_roles = pages_with_standard_roles.run(as_dict=True) + + for p in pages_with_standard_roles: + if p.name not in has_role: + has_role[p.name] = {"modified": p.modified, "title": p.title} + if parent == "Report": + has_role[p.name].update({"ref_doctype": p.ref_doctype}) + + no_of_roles = SubQuery( + frappe.qb.from_(hasRole).select(Count("*")).where(hasRole.parent == parentTable.name) + ) + + # pages and reports with no role are allowed + rows_with_no_roles = ( + frappe.qb.from_(parentTable) + .select(parentTable.name, parentTable.modified, *columns) + .where(no_of_roles == 0) + ).run(as_dict=True) + + for r in rows_with_no_roles: + if r.name not in has_role: + has_role[r.name] = {"modified": r.modified, "title": r.title} + if is_report: + has_role[r.name] |= {"ref_doctype": r.ref_doctype} + + if is_report: + if not has_permission("Report", user=user, print_logs=False): + return {} + + reports = frappe.get_list( + "Report", + fields=["name", "report_type"], + filters={"name": ("in", has_role.keys())}, + ignore_ifnull=True, + user=user, + ) + for report in reports: + has_role[report.name]["report_type"] = report.report_type + + non_permitted_reports = set(has_role.keys()) - {r.name for r in reports} + for r in non_permitted_reports: + has_role.pop(r, None) + + return has_role diff --git a/frappe/desk/desktop.py b/frappe/desk/desktop.py index fb1d2018802e..85793902255b 100644 --- a/frappe/desk/desktop.py +++ b/frappe/desk/desktop.py @@ -7,13 +7,9 @@ import frappe from frappe import DoesNotExistError, ValidationError, _, _dict -from frappe.boot import get_allowed_pages, get_allowed_reports -from frappe.cache_manager import ( - build_domain_restricted_doctype_cache, - build_domain_restricted_page_cache, - build_table_count_cache, -) +from frappe.cache_manager import build_table_count_cache from frappe.core.doctype.custom_role.custom_role import get_custom_allowed_roles +from frappe.desk.desk_views import DeskViews def handle_not_exist(fn): @@ -28,7 +24,7 @@ def wrapper(*args, **kwargs): return wrapper -class Workspace: +class Workspace(DeskViews): def __init__(self, page, minimal=False): self.page_name = page.get("name") self.page_title = page.get("title") @@ -49,8 +45,8 @@ def __init__(self, page, minimal=False): self.can_read = self.get_cached("user_perm_can_read", self.get_can_read_items) - self.allowed_pages = get_allowed_pages(cache=True) - self.allowed_reports = get_allowed_reports(cache=True) + self.allowed_pages = DeskViews.get_allowed_pages(cache=True) + self.allowed_reports = DeskViews.get_allowed_reports(cache=True) if not minimal: if self.doc.content: @@ -59,12 +55,6 @@ def __init__(self, page, minimal=False): ] self.table_counts = get_table_with_counts() - self.restricted_doctypes = ( - frappe.cache.get_value("domain_restricted_doctypes") or build_domain_restricted_doctype_cache() - ) - self.restricted_pages = ( - frappe.cache.get_value("domain_restricted_pages") or build_domain_restricted_page_cache() - ) def is_permitted(self): """Return true if `Has Role` is not set or the user is allowed.""" @@ -131,24 +121,6 @@ def get_onboarding_doc(self, onboarding): return doc - def is_item_allowed(self, name, item_type): - item_type = item_type.lower() - - if item_type == "doctype": - return name in (self.can_read or []) and name in (self.restricted_doctypes or []) - if item_type == "page": - return name in self.allowed_pages and name in self.restricted_pages - if item_type == "report": - return not frappe.db.get_value("Report", name, "disabled") and name in self.allowed_reports - if item_type == "help": - return True - if item_type == "dashboard": - return True - if item_type == "url": - return True - - return False - def build_workspace(self): self.cards = {"items": self.get_links()} self.charts = {"items": self.get_charts()} @@ -375,7 +347,7 @@ def get_desktop_page(page): @frappe.whitelist() -def get_workspace_sidebar_items(): +def get_workspaces(): """Get list of sidebar items for desk""" from frappe.modules.utils import get_module_app diff --git a/frappe/desk/doctype/calendar_view/calendar_view.json b/frappe/desk/doctype/calendar_view/calendar_view.json index 445c46a8e1bb..fa5d2853b35d 100644 --- a/frappe/desk/doctype/calendar_view/calendar_view.json +++ b/frappe/desk/doctype/calendar_view/calendar_view.json @@ -53,7 +53,7 @@ } ], "links": [], - "modified": "2024-03-23 16:01:29.668268", + "modified": "2026-05-28", "modified_by": "Administrator", "module": "Desk", "name": "Calendar View", @@ -71,10 +71,6 @@ "role": "System Manager", "share": 1, "write": 1 - }, - { - "read": 1, - "role": "Desk User" } ], "sort_field": "creation", diff --git a/frappe/desk/doctype/custom_html_block/custom_html_block.json b/frappe/desk/doctype/custom_html_block/custom_html_block.json index 6d8aadfbc48c..5f6cf9ede092 100644 --- a/frappe/desk/doctype/custom_html_block/custom_html_block.json +++ b/frappe/desk/doctype/custom_html_block/custom_html_block.json @@ -93,7 +93,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2024-03-23 16:02:15.380899", + "modified": "2026-05-28", "modified_by": "Administrator", "module": "Desk", "name": "Custom HTML Block", @@ -101,26 +101,8 @@ "owner": "Administrator", "permissions": [ { - "email": 1, - "export": 1, - "print": 1, "read": 1, - "report": 1, - "role": "Desk User", - "share": 1 - }, - { - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "if_owner": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "Desk User", - "share": 1, - "write": 1 + "role": "Desk User" }, { "create": 1, diff --git a/frappe/desk/doctype/dashboard/dashboard.json b/frappe/desk/doctype/dashboard/dashboard.json index 294abb64c3a4..0e3d023b671c 100644 --- a/frappe/desk/doctype/dashboard/dashboard.json +++ b/frappe/desk/doctype/dashboard/dashboard.json @@ -67,7 +67,7 @@ } ], "links": [], - "modified": "2024-03-23 16:02:16.071715", + "modified": "2026-05-28", "modified_by": "Administrator", "module": "Desk", "name": "Dashboard", @@ -98,13 +98,8 @@ "write": 1 }, { - "email": 1, - "export": 1, - "print": 1, "read": 1, - "report": 1, - "role": "Desk User", - "share": 1 + "role": "Desk User" } ], "quick_entry": 1, diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.json b/frappe/desk/doctype/dashboard_chart/dashboard_chart.json index 0a84efa97a1e..97db8e325ab8 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.json +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.json @@ -304,7 +304,7 @@ } ], "links": [], - "modified": "2025-06-08 22:49:08.587921", + "modified": "2026-05-28", "modified_by": "Administrator", "module": "Desk", "name": "Dashboard Chart", @@ -336,13 +336,8 @@ "write": 1 }, { - "email": 1, - "export": 1, - "print": 1, "read": 1, - "report": 1, - "role": "Desk User", - "share": 1 + "role": "Desk User" } ], "sort_field": "creation", diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py index 2fe829b50bb5..b669911fd674 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py @@ -6,10 +6,11 @@ import frappe from frappe import _ -from frappe.boot import get_allowed_report_names +from frappe.desk.desk_views import DeskViews from frappe.model.document import Document from frappe.model.naming import append_number_if_name_exists from frappe.modules.export_file import export_to_files +from frappe.permissions import get_doctypes_with_read from frappe.utils import cint, flt, get_datetime, getdate, has_common, now_datetime, nowdate from frappe.utils.dashboard import cache_source from frappe.utils.data import format_date @@ -38,9 +39,9 @@ def get_permission_query_conditions(user): module_condition = False allowed_doctypes = [frappe.db.escape(doctype) for doctype in frappe.permissions.get_doctypes_with_read()] - allowed_reports = [frappe.db.escape(report) for report in get_allowed_report_names()] + allowed_reports = [frappe.db.escape(report) for report in DeskViews.get_allowed_report_names(user=user)] allowed_modules = [ - frappe.db.escape(module.get("module_name")) for module in get_modules_from_all_apps_for_user() + frappe.db.escape(module.get("module_name")) for module in get_modules_from_all_apps_for_user(user) ] if allowed_doctypes: @@ -76,10 +77,10 @@ def has_permission(doc, ptype, user): if has_common(roles, allowed): return True elif doc.chart_type == "Report": - if doc.report_name in get_allowed_report_names(): + if doc.report_name in DeskViews.get_allowed_report_names(user=user): return True else: - allowed_doctypes = frappe.permissions.get_doctypes_with_read() + allowed_doctypes = get_doctypes_with_read(user) if doc.document_type in allowed_doctypes: return True @@ -284,14 +285,16 @@ def get_group_by_chart_config(chart, filters) -> dict | None: ) # get info about @group_by_field if data and group_by_field_field.fieldtype == "Link": # if @group_by_field is link - title_field = frappe.get_meta(group_by_field_field.options) # get title field - if title_field.title_field: # if has title_field - for item in data: # replace chart labels from name to title value - item.name = frappe.get_value(group_by_field_field.options, item.name, title_field.title_field) + meta = frappe.get_meta(group_by_field_field.options) # get title field + for item in data: # replace chart labels from name to title value + if meta.title_field: + item.name = frappe.get_value(group_by_field_field.options, item.name, meta.title_field) + elif meta.translated_doctype: + item.name = _(item.get("name", "Not Specified")) if data: return { - "labels": [item.get("name", "Not Specified") for item in data], + "labels": [item.name for item in data], "datasets": [{"name": chart.name, "values": [item["count"] for item in data]}], } return None diff --git a/frappe/desk/doctype/dashboard_settings/dashboard_settings.json b/frappe/desk/doctype/dashboard_settings/dashboard_settings.json index 86b77187e3ab..219448eb60be 100644 --- a/frappe/desk/doctype/dashboard_settings/dashboard_settings.json +++ b/frappe/desk/doctype/dashboard_settings/dashboard_settings.json @@ -27,7 +27,7 @@ ], "in_create": 1, "links": [], - "modified": "2024-03-23 16:02:16.763875", + "modified": "2026-05-28", "modified_by": "Administrator", "module": "Desk", "name": "Dashboard Settings", @@ -35,13 +35,8 @@ "owner": "Administrator", "permissions": [ { - "email": 1, - "export": 1, - "print": 1, "read": 1, - "report": 1, "role": "Desk User", - "share": 1, "write": 1 } ], diff --git a/frappe/desk/doctype/desktop_icon/desktop_icon.json b/frappe/desk/doctype/desktop_icon/desktop_icon.json index b954aabe6043..1c0548506738 100644 --- a/frappe/desk/doctype/desktop_icon/desktop_icon.json +++ b/frappe/desk/doctype/desktop_icon/desktop_icon.json @@ -151,7 +151,7 @@ } ], "links": [], - "modified": "2026-02-04 13:59:30.578370", + "modified": "2026-05-29", "modified_by": "Administrator", "module": "Desk", "name": "Desktop Icon", @@ -169,18 +169,6 @@ "role": "System Manager", "share": 1, "write": 1 - }, - { - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "Desk User", - "share": 1, - "write": 1 } ], "quick_entry": 1, diff --git a/frappe/desk/doctype/desktop_icon/desktop_icon.py b/frappe/desk/doctype/desktop_icon/desktop_icon.py index 9ab50fb45a95..bc76a89c5181 100644 --- a/frappe/desk/doctype/desktop_icon/desktop_icon.py +++ b/frappe/desk/doctype/desktop_icon/desktop_icon.py @@ -85,63 +85,6 @@ def delete_desktop_icon_file(self): if os.path.exists(file_path): os.remove(file_path) - def is_permitted(self, bootinfo): - icon_module = None - if self.icon_type == "Link" and self.link_to: - icon_module = frappe.db.get_value("Workspace", self.link_to, "module") - # module permission check - if icon_module: - blocked_modules = frappe.get_cached_doc("User", frappe.session.user).get_blocked_modules() - if icon_module in blocked_modules: - return False - # perform a permission check based on roles table (desktop icons) - allowed_roles = [d.role for d in self.get("roles") or []] - if allowed_roles and not set(allowed_roles).intersection(frappe.get_roles()): - return False - if self.icon_type == "Folder": - return True - elif self.icon_type == "App": - return self.check_app_permission() - else: - try: - items = bootinfo.workspace_sidebar_item[self.label.lower()]["items"] - - if len(items) and all(item["type"] == "Section Break" for item in items): - return False - if len(items) == 0: - return False - return True - except KeyError: - return False - - def check_app_permission(self): - for a in frappe.get_installed_apps(): - if frappe.get_hooks(app_name=a)["app_title"][0] == self.label or self.app == a: - app_detail = frappe.get_hooks("add_to_apps_screen", app_name=a) - if len(app_detail) != 0: - permission_method = app_detail[0].get("has_permission", None) - if permission_method: - return frappe.call(permission_method) - else: - return True - else: - # App hooks.py doesn't have add_to_apps_screen - return True - - # def is_permitted(self): - # """Return True if `Has Role` is not set or the user is allowed.""" - # from frappe.utils import has_common - - # allowed = [d.role for d in frappe.get_all("Has Role", fields=["role"], filters={"parent": self.name})] - - # if not allowed: - # return True - - # roles = frappe.get_roles() - - # if has_common(roles, allowed): - # return True - def after_insert(self): clear_desktop_icons_cache() @@ -160,6 +103,21 @@ def get_workspace_names(workspaces): return workspace_list +def check_app_permission(label, app): + for a in frappe.get_installed_apps(): + if frappe.get_hooks(app_name=a)["app_title"][0] == label or app == a: + app_detail = frappe.get_hooks("add_to_apps_screen", app_name=a) + if len(app_detail) != 0: + permission_method = app_detail[0].get("has_permission", None) + if permission_method: + return frappe.call(permission_method) + else: + return True + else: + # App hooks.py doesn't have add_to_apps_screen + return True + + def get_desktop_icons(user=None, bootinfo=None): """Return desktop icons for user""" if not user: @@ -211,8 +169,17 @@ def get_desktop_icons(user=None, bootinfo=None): permitted_parent_labels = set() if bootinfo: for s in user_icons: - icon = frappe.get_doc("Desktop Icon", s.name) - if icon.is_permitted(bootinfo): + if s.icon_type == "Folder": + permitted = True + elif s.icon_type == "App": + permitted = check_app_permission(s.label, s.app) + else: + # Workspace Sidebar link: present in the boot map ⇒ user can see at least + # one item in it (get_sidebar_items already enforces this). + sidebar = bootinfo.workspace_sidebar_item.get(s.label.lower()) + permitted = bool(sidebar and sidebar["items"]) + + if permitted: permitted_icons.append(s) if not s.parent_icon: @@ -325,15 +292,23 @@ def create_user_icons(user, data): @frappe.whitelist() def add_workspace_to_desktop(workspace: str): - sidebar = frappe.new_doc("Workspace Sidebar") - sidebar_item = frappe.new_doc("Workspace Sidebar Item") - sidebar_item.label = workspace - sidebar_item.type = "Link" - sidebar_item.link_to = workspace - sidebar_item.link_type = "Workspace" - sidebar.title = workspace - sidebar.append("items", sidebar_item) - sidebar.save() + if frappe.db.exists("Workspace Sidebar", workspace): + sidebar = frappe.get_doc("Workspace Sidebar", workspace) + else: + sidebar = frappe.new_doc("Workspace Sidebar") + sidebar.title = workspace + + if not any(item.link_to == workspace for item in sidebar.get("items", [])): + sidebar_item = frappe.new_doc("Workspace Sidebar Item") + sidebar_item.label = workspace + sidebar_item.type = "Link" + sidebar_item.link_to = workspace + sidebar_item.link_type = "Workspace" + sidebar.append("items", sidebar_item) + sidebar.save() + + if frappe.db.exists("Desktop Icon", workspace): + return {"icon": frappe.get_doc("Desktop Icon", workspace).as_dict()} new_icon = frappe.new_doc("Desktop Icon") new_icon.label = workspace diff --git a/frappe/desk/doctype/desktop_layout/desktop_layout.json b/frappe/desk/doctype/desktop_layout/desktop_layout.json index 91e516df8647..35a7ec3269b6 100644 --- a/frappe/desk/doctype/desktop_layout/desktop_layout.json +++ b/frappe/desk/doctype/desktop_layout/desktop_layout.json @@ -27,7 +27,7 @@ "grid_page_length": 50, "index_web_pages_for_search": 1, "links": [], - "modified": "2026-01-25 15:30:12.805037", + "modified": "2026-06-03", "modified_by": "Administrator", "module": "Desk", "name": "Desktop Layout", @@ -49,13 +49,8 @@ { "create": 1, "delete": 1, - "email": 1, - "export": 1, - "print": 1, "read": 1, - "report": 1, "role": "Desk User", - "share": 1, "write": 1 } ], diff --git a/frappe/desk/doctype/event/event.json b/frappe/desk/doctype/event/event.json index 5b792d8d526a..f87676ba9af4 100644 --- a/frappe/desk/doctype/event/event.json +++ b/frappe/desk/doctype/event/event.json @@ -364,23 +364,13 @@ "icon": "fa fa-calendar", "idx": 1, "links": [], - "modified": "2025-10-07 08:53:24.732646", + "modified": "2026-05-28", "modified_by": "shariq@frappe.io", "module": "Desk", "name": "Event", "naming_rule": "Expression (old style)", "owner": "Administrator", "permissions": [ - { - "create": 1, - "email": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "Desk User", - "share": 1, - "write": 1 - }, { "create": 1, "delete": 1, @@ -393,15 +383,6 @@ "role": "System Manager", "share": 1, "write": 1 - }, - { - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "All", - "share": 1 } ], "read_only": 1, diff --git a/frappe/desk/doctype/event/event.py b/frappe/desk/doctype/event/event.py index 11674434312f..72962c8cf4ac 100644 --- a/frappe/desk/doctype/event/event.py +++ b/frappe/desk/doctype/event/event.py @@ -4,6 +4,7 @@ import json from datetime import date, datetime +from typing import Any import frappe import frappe.share @@ -338,8 +339,14 @@ def send_event_digest(): @frappe.whitelist() @http_cache(max_age=5 * 60, stale_while_revalidate=60 * 60) def get_events( - start: date, end: date, user: str | None = None, for_reminder: bool = False, filters=None + start: str | date, + end: str | date, + user: str | None = None, + for_reminder: bool = False, + filters: str | list | dict[str, Any] | None = None, ) -> list[frappe._dict]: + start, end = getdate(start), getdate(end) + caller = frappe.session.user target_user = user or caller diff --git a/frappe/desk/doctype/event/test_event.py b/frappe/desk/doctype/event/test_event.py index 76679a05b7b6..b68a3c7ca482 100644 --- a/frappe/desk/doctype/event/test_event.py +++ b/frappe/desk/doctype/event/test_event.py @@ -8,7 +8,7 @@ from frappe.core.utils import find from frappe.desk.doctype.event.event import get_events from frappe.tests import IntegrationTestCase -from frappe.tests.utils import make_test_objects +from frappe.tests.utils import make_test_objects, toggle_test_mode class TestEvent(IntegrationTestCase): @@ -50,6 +50,26 @@ def test_event_list(self): self.assertFalse("_Test Event 3" in subjects) self.assertFalse("_Test Event 2" in subjects) + def test_get_events_for_different_timezone(self): + frappe.set_user("Administrator") + frappe.get_doc( + { + "doctype": "Event", + "subject": "_Test Event Different Timezone", + "event_type": "Public", + "starts_on": "2025-05-10 10:00:00", + } + ).insert() + + original_in_test = frappe.in_test + toggle_test_mode(True) + try: + events = get_events(start="2025-04-30 23:00:00", end="2025-06-10 23:00:00", user="Administrator") + finally: + toggle_test_mode(original_in_test) + + self.assertIn("_Test Event Different Timezone", [event.subject for event in events]) + def test_revert_logic(self): ev = frappe.get_doc(self.globalTestRecords["Event"][0]).insert() name = ev.name diff --git a/frappe/desk/doctype/form_tour/form_tour.json b/frappe/desk/doctype/form_tour/form_tour.json index aba602ae76ad..3fb9769d535c 100644 --- a/frappe/desk/doctype/form_tour/form_tour.json +++ b/frappe/desk/doctype/form_tour/form_tour.json @@ -178,7 +178,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2024-11-17 13:06:48.120836", + "modified": "2026-05-28", "modified_by": "Administrator", "module": "Desk", "name": "Form Tour", @@ -196,10 +196,6 @@ "role": "System Manager", "share": 1, "write": 1 - }, - { - "read": 1, - "role": "Desk User" } ], "sort_field": "creation", diff --git a/frappe/desk/doctype/global_search_doctype/global_search_doctype.json b/frappe/desk/doctype/global_search_doctype/global_search_doctype.json index fd380ab47bbb..c0558999446f 100644 --- a/frappe/desk/doctype/global_search_doctype/global_search_doctype.json +++ b/frappe/desk/doctype/global_search_doctype/global_search_doctype.json @@ -1,11 +1,13 @@ { "actions": [], + "allow_bulk_edit": 0, "creation": "2019-09-13 21:33:55.551941", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", "field_order": [ - "document_type" + "document_type", + "configure" ], "fields": [ { @@ -14,17 +16,24 @@ "in_list_view": 1, "label": "Document Type", "options": "DocType" + }, + { + "fieldname": "configure", + "fieldtype": "Button", + "in_list_view": 1, + "label": "Configure" } ], "istable": 1, "links": [], - "modified": "2024-03-23 16:03:26.489242", + "modified": "2026-04-27 21:41:43.954439", "modified_by": "Administrator", "module": "Desk", "name": "Global Search DocType", "owner": "Administrator", "permissions": [], "quick_entry": 1, + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], diff --git a/frappe/desk/doctype/global_search_settings/global_search_settings.js b/frappe/desk/doctype/global_search_settings/global_search_settings.js index 147a72eef16a..011dc5324901 100644 --- a/frappe/desk/doctype/global_search_settings/global_search_settings.js +++ b/frappe/desk/doctype/global_search_settings/global_search_settings.js @@ -1,6 +1,81 @@ // Copyright (c) 2019, Frappe Technologies and contributors // For license information, please see license.txt +function show_configure_search_fields_dialog(doctype, frm) { + frappe.call({ + method: "frappe.desk.doctype.global_search_settings.global_search_settings.get_global_search_field_options", + args: { doctype }, + callback(r) { + const options = r.message || []; + + const dialog = new frappe.ui.Dialog({ + title: __("Configure search fields"), + fields: [ + { + fieldtype: "HTML", + fieldname: "doctype_heading", + }, + { + fieldtype: "HTML", + fieldname: "search_bar", + }, + { + fieldname: "search_fields", + fieldtype: "MultiCheck", + columns: 2, + sort_options: false, + options, + }, + ], + + primary_action_label: __("Save"), + primary_action() { + const checked = dialog.get_field("search_fields").get_checked_options(); + frappe.call({ + method: "frappe.desk.doctype.global_search_settings.global_search_settings.update_global_search_fields", + args: { doctype, fields: checked }, + freeze: true, + freeze_message: __("Updating search index"), + callback: function (r) { + if (r.exc) { + frappe.msgprint(r.exc); + } else { + dialog.hide(); + frappe.show_alert({ + message: __("Search fields updated."), + indicator: "green", + }); + if (frm) { + frm.refresh(); + } + } + }, + }); + }, + }); + + dialog.get_field("doctype_heading").$wrapper.html(` +
${frappe.utils.escape_html(__(doctype))}
+ `); + + dialog.get_field("search_bar").$wrapper.html(` + + `); + + dialog.show(); + + frappe.utils.setup_search(dialog.$body, ".unit-checkbox", ".label-area"); + }, + }); +} + frappe.ui.form.on("Global Search Settings", { refresh: function (frm) { frappe.realtime.on("global_search_settings", (data) => { @@ -30,3 +105,16 @@ frappe.ui.form.on("Global Search Settings", { }); }, }); + +frappe.ui.form.on("Global Search DocType", { + configure: function (frm, cdt, cdn) { + const row = frappe.get_doc(cdt, cdn); + if (!row.document_type) { + frappe.msgprint(__("Please select Document Type first.")); + return; + } + frappe.model.with_doctype(row.document_type, () => { + show_configure_search_fields_dialog(row.document_type, frm); + }); + }, +}); 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 f84c5e510825..e8414256d94f 100644 --- a/frappe/desk/doctype/global_search_settings/global_search_settings.py +++ b/frappe/desk/doctype/global_search_settings/global_search_settings.py @@ -3,6 +3,8 @@ import frappe from frappe import _ +from frappe.custom.doctype.customize_form.customize_form import CustomizeForm +from frappe.model import NO_VALUE_FIELDS from frappe.model.document import Document @@ -101,3 +103,109 @@ def show_message(progress, msg): {"progress": progress, "total": 3, "msg": msg}, user=frappe.session.user, ) + + +def _eligible_global_search_docfields(meta): + for df in sorted(meta.fields, key=lambda x: x.idx or 0): + if df.fieldtype in NO_VALUE_FIELDS: + continue + if getattr(df, "hidden", False): + continue + if getattr(df, "is_virtual", False): + continue + yield df + + +@frappe.whitelist() +def get_global_search_field_options(doctype: str | None = None): + """ + Get the global search field options which is list of fields with checked status if that field is in global search + and name is also included if show_name_in_global_search is set + """ + if not doctype: + frappe.throw(_("Document Type is required")) + + frappe.only_for("System Manager") + + meta = frappe.get_meta(doctype) + + options = [ + { + "label": _("Document Name (ID)"), + "value": "name", + "checked": bool(getattr(meta, "show_name_in_global_search", False)), + } + ] + + for df in _eligible_global_search_docfields(meta): + options.append( + { + "label": _(df.label, context=df.parent), + "value": df.fieldname, + "checked": bool(df.in_global_search), + } + ) + + return options + + +def _customize_form_stub(doctype: str) -> CustomizeForm: + """In-memory Customize Form — same PS helpers as desk Customize Form.""" + cf = frappe.new_doc("Customize Form") + cf.doc_type = doctype + return cf + + +def _set_global_search_property_setter(cf: CustomizeForm, fieldname: str, enabled: bool) -> None: + """Toggle DocType / DocField flags via `CustomizeForm.make_property_setter` (same as Customize Form desk save).""" + val = 1 if enabled else 0 + if fieldname == "name": + cf.make_property_setter("show_name_in_global_search", val, "Check") + else: + cf.make_property_setter("in_global_search", val, "Check", fieldname=fieldname) + + +@frappe.whitelist() +def update_global_search_fields(doctype: str, fields: str): + """Apply global-search field selection via the same Property Setter path as Customize Form.""" + + frappe.only_for("System Manager") + if not doctype: + frappe.throw(_("Document Type is required")) + if frappe.get_meta(doctype).module == "Core": + frappe.throw(_("Cannot configure Core DocTypes for Global Search.")) + + fields = frappe.parse_json(fields) + meta = frappe.get_meta(doctype) + + # Current set of global search fields which are in the database + current = {df.fieldname for df in _eligible_global_search_docfields(meta) if df.in_global_search} + if bool(getattr(meta, "show_name_in_global_search", False)): + current.add("name") + + # Desired set of global search fields which are in the request + desired = set(fields) + + # So basically we need to add the fields that are in the request and are not in the database + # and remove the fields that are in the database and are not in the request + + # Create a Customize Form stub to apply property setters + cf = _customize_form_stub(doctype) + + # Add the fields that are in the request and are not in the database + for fieldname in desired - current: + _set_global_search_property_setter(cf, fieldname, True) + + # Remove the fields that are in the database and are not in the request + for fieldname in current - desired: + _set_global_search_property_setter(cf, fieldname, False) + + # Clear the cache and enqueue the rebuild for the doctype + frappe.clear_cache(doctype=doctype) + frappe.enqueue( + "frappe.utils.global_search.rebuild_for_doctype", + doctype=doctype, + enqueue_after_commit=True, + ) + + return {"success": True} diff --git a/frappe/desk/doctype/kanban_board/kanban_board.json b/frappe/desk/doctype/kanban_board/kanban_board.json index f15b2e755b6e..0bf3f471f722 100644 --- a/frappe/desk/doctype/kanban_board/kanban_board.json +++ b/frappe/desk/doctype/kanban_board/kanban_board.json @@ -84,7 +84,7 @@ } ], "links": [], - "modified": "2024-03-23 16:03:28.240133", + "modified": "2026-05-28", "modified_by": "Administrator", "module": "Desk", "name": "Kanban Board", @@ -92,16 +92,8 @@ "owner": "Administrator", "permissions": [ { - "read": 1, - "role": "Desk User" - }, - { - "create": 1, - "delete": 1, - "if_owner": 1, - "read": 1, "role": "Desk User", - "write": 1 + "select": 1 }, { "create": 1, diff --git a/frappe/desk/doctype/list_filter/list_filter.json b/frappe/desk/doctype/list_filter/list_filter.json index 2d029a3f6f01..3f6e7e2dd597 100644 --- a/frappe/desk/doctype/list_filter/list_filter.json +++ b/frappe/desk/doctype/list_filter/list_filter.json @@ -36,7 +36,7 @@ ], "in_create": 1, "links": [], - "modified": "2024-03-23 16:03:29.075357", + "modified": "2026-05-28", "modified_by": "Administrator", "module": "Desk", "name": "List Filter", @@ -45,13 +45,8 @@ { "create": 1, "delete": 1, - "email": 1, - "export": 1, - "print": 1, "read": 1, - "report": 1, "role": "Desk User", - "share": 1, "write": 1 } ], diff --git a/frappe/desk/doctype/module_onboarding/module_onboarding.json b/frappe/desk/doctype/module_onboarding/module_onboarding.json index 099c75083e16..8a7c2bfc7fc2 100644 --- a/frappe/desk/doctype/module_onboarding/module_onboarding.json +++ b/frappe/desk/doctype/module_onboarding/module_onboarding.json @@ -61,7 +61,7 @@ } ], "links": [], - "modified": "2026-02-20 13:30:25.659490", + "modified": "2026-05-28", "modified_by": "Administrator", "module": "Desk", "name": "Module Onboarding", @@ -78,15 +78,6 @@ "role": "System Manager", "share": 1, "write": 1 - }, - { - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "Desk User", - "share": 1 } ], "read_only": 1, diff --git a/frappe/desk/doctype/note/note.json b/frappe/desk/doctype/note/note.json index 530e084c1948..9da2f20c11d4 100644 --- a/frappe/desk/doctype/note/note.json +++ b/frappe/desk/doctype/note/note.json @@ -87,7 +87,7 @@ "icon": "fa fa-file-text", "idx": 1, "links": [], - "modified": "2025-03-26 18:19:45.079747", + "modified": "2026-05-28", "modified_by": "Administrator", "module": "Desk", "name": "Note", @@ -119,19 +119,14 @@ "write": 1 }, { - "export": 1, - "print": 1, "read": 1, - "report": 1, "role": "Desk User" }, { "create": 1, "delete": 1, - "email": 1, "if_owner": 1, "role": "Desk User", - "share": 1, "write": 1 }, { diff --git a/frappe/desk/doctype/note/note.py b/frappe/desk/doctype/note/note.py index 179994e5a798..aab8b239e72c 100644 --- a/frappe/desk/doctype/note/note.py +++ b/frappe/desk/doctype/note/note.py @@ -56,6 +56,7 @@ def mark_seen_by(self, user: str) -> None: @frappe.whitelist() def mark_as_seen(note: str): note: Note = frappe.get_doc("Note", note) + note.check_permission("read") note.mark_seen_by(frappe.session.user) note.save(ignore_permissions=True, ignore_version=True) diff --git a/frappe/desk/doctype/notification_log/notification_log.json b/frappe/desk/doctype/notification_log/notification_log.json index 4411780a9e7d..87f5b31201f5 100644 --- a/frappe/desk/doctype/notification_log/notification_log.json +++ b/frappe/desk/doctype/notification_log/notification_log.json @@ -109,20 +109,15 @@ "hide_toolbar": 1, "in_create": 1, "links": [], - "modified": "2025-05-30 20:17:44.969738", + "modified": "2026-05-28", "modified_by": "Administrator", "module": "Desk", "name": "Notification Log", "owner": "Administrator", "permissions": [ { - "email": 1, - "export": 1, - "print": 1, "read": 1, - "report": 1, - "role": "All", - "share": 1 + "role": "All" } ], "row_format": "Dynamic", diff --git a/frappe/desk/doctype/notification_log/notification_log.py b/frappe/desk/doctype/notification_log/notification_log.py index 5b4f49526c39..8eb5ab52c5d6 100644 --- a/frappe/desk/doctype/notification_log/notification_log.py +++ b/frappe/desk/doctype/notification_log/notification_log.py @@ -199,7 +199,13 @@ def mark_as_read(docname: str): return if docname: - frappe.db.set_value("Notification Log", str(docname), "read", 1, update_modified=False) + frappe.db.set_value( + "Notification Log", + {"name": str(docname), "for_user": frappe.session.user}, + "read", + 1, + update_modified=False, + ) @frappe.whitelist() diff --git a/frappe/desk/doctype/notification_settings/notification_settings.json b/frappe/desk/doctype/notification_settings/notification_settings.json index 7f73c2bff44b..88521b19e046 100644 --- a/frappe/desk/doctype/notification_settings/notification_settings.json +++ b/frappe/desk/doctype/notification_settings/notification_settings.json @@ -37,7 +37,7 @@ "label": "Email Settings" }, { - "default": "1", + "default": "0", "fieldname": "enable_email_notifications", "fieldtype": "Check", "label": "Enable Email Notifications" @@ -98,7 +98,7 @@ "in_create": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2026-02-24 11:06:24.112935", + "modified": "2026-05-28", "modified_by": "Administrator", "module": "Desk", "name": "Notification Settings", @@ -107,13 +107,8 @@ "permissions": [ { "create": 1, - "email": 1, - "export": 1, - "print": 1, "read": 1, - "report": 1, "role": "All", - "share": 1, "write": 1 } ], diff --git a/frappe/desk/doctype/number_card/number_card.json b/frappe/desk/doctype/number_card/number_card.json index 089786fbdcac..cd82452b7008 100644 --- a/frappe/desk/doctype/number_card/number_card.json +++ b/frappe/desk/doctype/number_card/number_card.json @@ -55,11 +55,11 @@ "options": "Count\nSum\nAverage\nMinimum\nMaximum" }, { - "depends_on": "eval: doc.type === 'Document Type' && doc.function !== 'Count'", + "depends_on": "eval: doc.type === 'Document Type' && doc.function !== 'Count'", "fieldname": "aggregate_function_based_on", "fieldtype": "Select", "label": "Aggregate Function Based On", - "mandatory_depends_on": "eval: doc.function !== 'Count'" + "mandatory_depends_on": "eval: doc.type === 'Document Type' && doc.function !== 'Count'" }, { "fieldname": "filters_json", @@ -229,7 +229,7 @@ } ], "links": [], - "modified": "2025-09-17 21:00:11.351605", + "modified": "2026-05-29", "modified_by": "Administrator", "module": "Desk", "name": "Number Card", @@ -260,13 +260,8 @@ "write": 1 }, { - "email": 1, - "export": 1, - "print": 1, "read": 1, - "report": 1, - "role": "Desk User", - "share": 1 + "role": "Desk User" } ], "row_format": "Dynamic", diff --git a/frappe/desk/doctype/number_card/number_card.py b/frappe/desk/doctype/number_card/number_card.py index 19b45ea589f5..20951f392dfa 100644 --- a/frappe/desk/doctype/number_card/number_card.py +++ b/frappe/desk/doctype/number_card/number_card.py @@ -3,7 +3,7 @@ import frappe from frappe import _ -from frappe.boot import get_allowed_report_names +from frappe.desk.desk_views import DeskViews from frappe.model.document import Document from frappe.model.naming import append_number_if_name_exists from frappe.modules.export_file import export_to_files @@ -79,42 +79,40 @@ def on_update(self): def get_permission_query_conditions(user=None): - # The user param is ignored because `get_allowed_report_names` and `get_doctypes_with_read` don't support it. - if frappe.session.user == "Administrator": - return + if not user: + user = frappe.session.user - if "System Manager" in frappe.get_roles(): + if user == "Administrator" or "System Manager" in frappe.get_roles(user): return - allowed_reports = get_allowed_report_names() - allowed_doctypes = get_doctypes_with_read() - allowed_modules = [module.get("module_name") for module in get_modules_from_all_apps_for_user()] + allowed_reports = DeskViews.get_allowed_report_names(user=user) + allowed_doctypes = get_doctypes_with_read(user) + allowed_modules = [module.get("module_name") for module in get_modules_from_all_apps_for_user(user)] nc = frappe.qb.DocType("Number Card") conditions = ( ((nc.type == "Report") & nc.report_name.isin(allowed_reports)) | ((nc.type == "Custom") & nc.document_type.isin(allowed_doctypes)) | ((nc.type == "Document Type") & nc.document_type.isin(allowed_doctypes)) - ) & (nc.module.isin(allowed_modules) | nc.module.isnull() | nc.module == "") + ) & (nc.module.isin(allowed_modules) | nc.module.isnull() | (nc.module == "")) return conditions.get_sql(quote_char="`") def has_permission(doc, ptype, user): - # The user param is ignored because `get_allowed_report_names` and `get_doctypes_with_read` don't support it. - if frappe.session.user == "Administrator": - return True + if not user: + user = frappe.session.user - if "System Manager" in frappe.get_roles(): + if user == "Administrator" or "System Manager" in frappe.get_roles(user): return True - if doc.type == "Report" and doc.report_name in get_allowed_report_names(): + if doc.type == "Report" and doc.report_name in DeskViews.get_allowed_report_names(user=user): return True - if doc.type == "Custom" and doc.document_type in get_doctypes_with_read(): + if doc.type == "Custom" and doc.document_type in get_doctypes_with_read(user): return True - if doc.type == "Document Type" and doc.document_type in get_doctypes_with_read(): + if doc.type == "Document Type" and doc.document_type in get_doctypes_with_read(user): return True return False @@ -221,9 +219,14 @@ def get_cards_for_user(doctype, txt, searchfield, start, page_len, filters): filters=filters, ) + allowed_modules = {module.get("module_name") for module in get_modules_from_all_apps_for_user()} + return ( condition_query.select(numberCard.name, numberCard.label, numberCard.document_type) .where((numberCard.owner == frappe.session.user) | (numberCard.is_public == 1)) + .where( + numberCard.module.isin(allowed_modules) | numberCard.module.isnull() | (numberCard.module == "") + ) .where(Criterion.any(search_conditions)) ).run() diff --git a/frappe/desk/doctype/number_card/test_number_card.py b/frappe/desk/doctype/number_card/test_number_card.py index ee78aeb7f66b..f6e465acd309 100644 --- a/frappe/desk/doctype/number_card/test_number_card.py +++ b/frappe/desk/doctype/number_card/test_number_card.py @@ -1,8 +1,137 @@ # Copyright (c) 2020, Frappe Technologies and Contributors # License: MIT. See LICENSE -# import frappe +import frappe +from frappe.desk.doctype.number_card.number_card import get_cards_for_user from frappe.tests import IntegrationTestCase class TestNumberCard(IntegrationTestCase): - pass + def test_link_search_hides_cards_from_blocked_modules(self): + user = "test2@example.com" + blocked_module = "Contacts" + blocked_card_name = "Test Blocked Module Card" + allowed_card_name = "Test No Module Card" + + frappe.set_user("Administrator") + + admin_blocked = frappe.get_cached_doc("User", "Administrator").get_blocked_modules() + self.assertNotIn(blocked_module, admin_blocked, f"{blocked_module} globally blocked — test invalid") + + for card_name in (blocked_card_name, allowed_card_name): + frappe.delete_doc("Number Card", card_name, ignore_missing=True, force=True) + self.addCleanup( + lambda c=card_name: frappe.delete_doc("Number Card", c, ignore_missing=True, force=True) + ) + + user_doc = frappe.get_doc("User", user) + if blocked_module not in {d.module for d in user_doc.block_modules}: + user_doc.append("block_modules", {"module": blocked_module}) + user_doc.save(ignore_permissions=True) + frappe.clear_document_cache("User", user) + + def restore_blocks(): + doc = frappe.get_doc("User", user) + doc.block_modules = {d for d in doc.block_modules if d.module != blocked_module} + doc.save(ignore_permissions=True) + frappe.clear_document_cache("User", user) + + self.addCleanup(restore_blocks) + + frappe.get_doc( + { + "doctype": "Number Card", + "label": blocked_card_name, + "type": "Document Type", + "document_type": "ToDo", + "function": "Count", + "is_public": 1, + "module": blocked_module, + } + ).insert(ignore_permissions=True) + + frappe.get_doc( + { + "doctype": "Number Card", + "label": allowed_card_name, + "type": "Document Type", + "document_type": "ToDo", + "function": "Count", + "is_public": 1, + } + ).insert(ignore_permissions=True) + + frappe.set_user(user) + try: + blocked_results = get_cards_for_user( + doctype="Number Card", + txt=blocked_card_name, + searchfield="name", + start=0, + page_len=20, + filters={}, + ) + allowed_results = get_cards_for_user( + doctype="Number Card", + txt=allowed_card_name, + searchfield="name", + start=0, + page_len=20, + filters={}, + ) + finally: + frappe.set_user("Administrator") + + self.assertEqual([row[0] for row in blocked_results], []) + self.assertEqual([row[0] for row in allowed_results], [allowed_card_name]) + + def test_report_card_hidden_when_report_is_not_allowed(self): + user = "test2@example.com" + report_name = "Test Restricted Number Card Report" + card_name = "Test Restricted Report Number Card" + baseline_role = "Desk User" + exclusive_role = "System Manager" + + frappe.set_user("Administrator") + frappe.delete_doc("Number Card", card_name, ignore_missing=True, force=True) + frappe.delete_doc("Report", report_name, ignore_missing=True, force=True) + self.addCleanup(lambda: frappe.delete_doc("Number Card", card_name, ignore_missing=True, force=True)) + self.addCleanup(lambda: frappe.delete_doc("Report", report_name, ignore_missing=True, force=True)) + + user_doc = frappe.get_doc("User", user) + had_baseline_role = baseline_role in frappe.get_roles(user) + if not had_baseline_role: + user_doc.add_roles(baseline_role) + self.addCleanup(lambda: user_doc.remove_roles(baseline_role)) + + had_exclusive_role = exclusive_role in frappe.get_roles(user) + if had_exclusive_role: + user_doc.remove_roles(exclusive_role) + self.addCleanup(lambda: user_doc.add_roles(exclusive_role)) + + report = frappe.get_doc( + { + "doctype": "Report", + "report_name": report_name, + "ref_doctype": "ToDo", + "report_type": "Report Builder", + "is_standard": "No", + "roles": [{"role": exclusive_role}], + } + ).insert(ignore_permissions=True) + + card = frappe.get_doc( + { + "doctype": "Number Card", + "label": card_name, + "type": "Report", + "report_name": report.name, + "function": "Count", + "report_field": "name", + } + ).insert(ignore_permissions=True) + + self.assertFalse(frappe.has_permission("Number Card", doc=card, user=user)) + self.assertNotIn( + card.name, + frappe.get_list("Number Card", filters={"name": card.name}, pluck="name", user=user), + ) diff --git a/frappe/desk/doctype/onboarding_step/onboarding_step.json b/frappe/desk/doctype/onboarding_step/onboarding_step.json index 7037c334bf2d..f97a8f97ac32 100644 --- a/frappe/desk/doctype/onboarding_step/onboarding_step.json +++ b/frappe/desk/doctype/onboarding_step/onboarding_step.json @@ -228,7 +228,7 @@ } ], "links": [], - "modified": "2026-02-23 21:03:51.131292", + "modified": "2026-05-28", "modified_by": "Administrator", "module": "Desk", "name": "Onboarding Step", @@ -246,15 +246,6 @@ "role": "Administrator", "share": 1, "write": 1 - }, - { - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "Desk User", - "share": 1 } ], "quick_entry": 1, diff --git a/frappe/desk/doctype/sidebar_item_group/sidebar_item_group.py b/frappe/desk/doctype/sidebar_item_group/sidebar_item_group.py index 7b72e2139f90..6759d1357c5e 100644 --- a/frappe/desk/doctype/sidebar_item_group/sidebar_item_group.py +++ b/frappe/desk/doctype/sidebar_item_group/sidebar_item_group.py @@ -4,7 +4,7 @@ import os import frappe -from frappe.boot import get_allowed_pages, get_allowed_reports +from frappe.desk.desk_views import DeskViews from frappe.model.document import Document from frappe.modules.utils import create_directory_on_app_path, get_app_level_directory_path @@ -48,11 +48,12 @@ def delete_file(self): @frappe.whitelist() -def get_reports(module_name=None): +def get_reports(module_name: str | None = None): reports_info = [] if module_name: sidebar_group = frappe.get_doc("Sidebar Item Group", module_name) for report_links in sidebar_group.links: - if report_links.report in get_allowed_reports().keys(): - reports_info.append(get_allowed_reports()[report_links.report]) + allowed_reports = DeskViews.get_allowed_reports() + if report_links.report in allowed_reports: + reports_info.append(allowed_reports[report_links.report]) return reports_info diff --git a/frappe/desk/doctype/tag/tag.json b/frappe/desk/doctype/tag/tag.json index 5385a89c72d7..adb897f5e19b 100644 --- a/frappe/desk/doctype/tag/tag.json +++ b/frappe/desk/doctype/tag/tag.json @@ -17,7 +17,7 @@ } ], "links": [], - "modified": "2024-03-23 16:03:39.682626", + "modified": "2026-05-28", "modified_by": "Administrator", "module": "Desk", "name": "Tag", @@ -37,13 +37,8 @@ }, { "create": 1, - "email": 1, - "export": 1, - "print": 1, "read": 1, - "report": 1, "role": "All", - "share": 1, "write": 1 } ], diff --git a/frappe/desk/doctype/tag_link/tag_link.json b/frappe/desk/doctype/tag_link/tag_link.json index 9613c7d0ed64..7c6e77979867 100644 --- a/frappe/desk/doctype/tag_link/tag_link.json +++ b/frappe/desk/doctype/tag_link/tag_link.json @@ -46,7 +46,7 @@ } ], "links": [], - "modified": "2025-02-04 12:02:03.779623", + "modified": "2026-05-28", "modified_by": "Administrator", "module": "Desk", "name": "Tag Link", @@ -66,13 +66,8 @@ }, { "create": 1, - "email": 1, - "export": 1, - "print": 1, "read": 1, - "report": 1, "role": "All", - "share": 1, "write": 1 } ], diff --git a/frappe/desk/doctype/todo/todo.json b/frappe/desk/doctype/todo/todo.json index 3186a0a84609..c7b589a6e971 100644 --- a/frappe/desk/doctype/todo/todo.json +++ b/frappe/desk/doctype/todo/todo.json @@ -161,7 +161,7 @@ "icon": "fa fa-check", "idx": 2, "links": [], - "modified": "2024-03-23 16:03:58.758787", + "modified": "2026-05-28", "modified_by": "Administrator", "module": "Desk", "name": "ToDo", @@ -170,12 +170,8 @@ { "create": 1, "delete": 1, - "email": 1, - "print": 1, "read": 1, - "report": 1, "role": "All", - "share": 1, "write": 1 }, { diff --git a/frappe/desk/doctype/workspace/workspace.json b/frappe/desk/doctype/workspace/workspace.json index f3422cd6eb99..16e4a2b9ef5b 100644 --- a/frappe/desk/doctype/workspace/workspace.json +++ b/frappe/desk/doctype/workspace/workspace.json @@ -2,7 +2,6 @@ "actions": [], "allow_rename": 1, "autoname": "field:label", - "beta": 1, "creation": "2020-01-23 13:45:59.470592", "doctype": "DocType", "editable_grid": 1, @@ -262,7 +261,7 @@ ], "in_create": 1, "links": [], - "modified": "2024-09-26 15:47:24.710881", + "modified": "2026-05-30", "modified_by": "Administrator", "module": "Desk", "name": "Workspace", @@ -289,13 +288,14 @@ "print": 1, "read": 1, "report": 1, - "role": "Desk User", + "role": "System Manager", "share": 1, "write": 1 } ], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/frappe/desk/doctype/workspace/workspace.py b/frappe/desk/doctype/workspace/workspace.py index cddafa1290c8..2030934d7f6f 100644 --- a/frappe/desk/doctype/workspace/workspace.py +++ b/frappe/desk/doctype/workspace/workspace.py @@ -7,7 +7,7 @@ import frappe from frappe import _ from frappe.boot import get_sidebar_items -from frappe.desk.desktop import get_workspace_sidebar_items, save_new_widget +from frappe.desk.desktop import get_workspaces, save_new_widget from frappe.desk.doctype.workspace_sidebar.workspace_sidebar import add_to_my_workspace from frappe.desk.utils import validate_route_conflict from frappe.model.document import Document @@ -292,7 +292,7 @@ def get_report_type(report): @frappe.whitelist() -def new_page(new_page): +def new_page(new_page: str): if not loads(new_page): return @@ -331,7 +331,7 @@ def new_page(new_page): # add to workspace sidebar items if not doc.public: add_to_my_workspace(doc) - workspaces = get_workspace_sidebar_items() + workspaces = get_workspaces() return {"workspace_pages": workspaces, "sidebar_items": get_sidebar_items(workspaces)} @@ -340,8 +340,12 @@ def save_page(name, public, new_widgets, blocks): public = frappe.parse_json(public) doc = frappe.get_doc("Workspace", name) - if not (is_workspace_manager() and doc.for_user == frappe.session.user): - return + can_edit = is_workspace_manager() or (not doc.public and doc.for_user == frappe.session.user) + if not can_edit: + frappe.throw( + _("You need the Workspace Manager role to edit this workspace."), + frappe.PermissionError, + ) if not doc.type: doc.type = "Workspace" @@ -358,9 +362,11 @@ def update_page(name, title, icon, indicator_color, parent, public): public = frappe.parse_json(public) doc = frappe.get_doc("Workspace", name) - if not doc.get("public") and doc.get("for_user") != frappe.session.user and not is_workspace_manager(): + if doc.get("public") and not is_workspace_manager(): + frappe.throw(_("Need Workspace Manager role to edit public workspaces.")) + elif not doc.get("public") and doc.get("for_user") != frappe.session.user and not is_workspace_manager(): frappe.throw( - _("Need Workspace Manager role to edit private workspace of other users"), + _("Need Workspace Manager role to edit private workspace of other users."), frappe.PermissionError, ) diff --git a/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.json b/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.json index 9b14ba8d4ad2..ebe8f77c2fde 100644 --- a/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.json +++ b/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.json @@ -79,7 +79,7 @@ "grid_page_length": 50, "index_web_pages_for_search": 1, "links": [], - "modified": "2026-02-20 15:19:27.520469", + "modified": "2026-05-29", "modified_by": "Administrator", "module": "Desk", "name": "Workspace Sidebar", @@ -97,18 +97,6 @@ "role": "System Manager", "share": 1, "write": 1 - }, - { - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "Desk User", - "share": 1, - "write": 1 } ], "row_format": "Dynamic", diff --git a/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.py b/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.py index a1fe322d71ac..534ad483cb0f 100644 --- a/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.py +++ b/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.py @@ -8,14 +8,14 @@ import frappe from frappe import _ -from frappe.boot import get_allowed_pages, get_allowed_reports +from frappe.desk.desk_views import DeskViews from frappe.model.document import Document from frappe.modules.export_file import strip_default_fields from frappe.modules.utils import create_directory_on_app_path from frappe.utils.caching import site_cache -class WorkspaceSidebar(Document): +class WorkspaceSidebar(Document, DeskViews): # begin: auto-generated types # This code is auto-generated. Do not modify anything in this block. @@ -39,12 +39,6 @@ def __init__(self, *args, **kwargs): if not frappe.flags.in_migrate: self.user = frappe.get_user() self.can_read = self.get_cached("user_perm_can_read", self.get_can_read_items) - self.allowed_modules = self.get_cached("user_allowed_modules", self.get_allowed_modules) - - self.allowed_pages = get_allowed_pages(cache=True) - self.allowed_reports = get_allowed_reports(cache=True) - self.restricted_doctypes = frappe.cache.get_value("domain_restricted_doctypes") - self.restricted_pages = frappe.cache.get_value("domain_restricted_pages") def get_can_read_items(self): if not self.user.can_read: @@ -75,33 +69,9 @@ def on_trash(self): frappe.throw(_("You need to be Workspace Manager to delete a public workspace.")) def after_rename(self, old, new, merge): - delete_file(self.app, old) - self.export_sidebar() - - def is_item_allowed(self, name, item_type, allowed_workspaces): - if frappe.session.user == "Administrator": - return True - - item_type = item_type.lower() - - if item_type == "doctype": - return ( - name in (self.can_read or []) - and name in (self.restricted_doctypes or []) - and frappe.has_permission(name) - ) - if item_type == "page": - return name in self.allowed_pages and name in self.restricted_pages - if item_type == "report": - return name in self.allowed_reports - if item_type == "help": - return True - if item_type == "dashboard": - return True - if item_type == "url": - return True - if item_type == "workspace": - return name in allowed_workspaces + if self.standard: + delete_file(self.app, old) + self.export_sidebar() def get_cached(self, cache_key, fallback_fn): value = frappe.cache.get_value(cache_key, user=frappe.session.user) @@ -133,12 +103,6 @@ def get_module_from_items(self): if counts and counts.most_common(1)[0]: return counts.most_common(1)[0][0] - def get_allowed_modules(self): - if not self.user.allow_modules: - self.user.build_permissions() - - return self.user.allow_modules - def delete_file(app, title): folder_path = create_directory_on_app_path("workspace_sidebar", app) @@ -275,10 +239,7 @@ def auto_generate_sidebar_from_module(): """Auto generate sidebar from module""" sidebars = [] for module in frappe.get_all("Module Def", pluck="name"): - if not ( - frappe.db.exists("Workspace Sidebar", {"module": module, "for_user": None}) - or frappe.db.exists("Workspace Sidebar", {"name": module, "for_user": None}) - ): + if not (frappe.db.exists("Workspace Sidebar", {"name": module, "for_user": None})): module_info = get_module_info(module) sidebar_items = create_sidebar_items(module_info) sidebar = frappe.new_doc("Workspace Sidebar") diff --git a/frappe/desk/form/assign_to.py b/frappe/desk/form/assign_to.py index bcebd4db0ed6..1ad9e2b830e2 100644 --- a/frappe/desk/form/assign_to.py +++ b/frappe/desk/form/assign_to.py @@ -40,7 +40,7 @@ def get(args=None): @frappe.whitelist() -def add(args=None, *, ignore_permissions=False): +def add(args=None): """add in someone's to do list args = { "assign_to": [], @@ -51,6 +51,10 @@ def add(args=None, *, ignore_permissions=False): } """ + return _add(args, ignore_permissions=False) + + +def _add(args=None, *, ignore_permissions=False): if not args: args = frappe.local.form_dict @@ -109,7 +113,7 @@ def add(args=None, *, ignore_permissions=False): ) frappe.throw(msg, title=_("Missing Permission")) else: - frappe.share.add(doc.doctype, doc.name, assign_to) + frappe.share.add(doc.doctype, str(doc.name), assign_to) shared_with_users.append(assign_to) # make this document followed by assigned user @@ -174,12 +178,16 @@ def close_all_assignments(doctype, name, ignore_permissions=False): @frappe.whitelist() -def remove(doctype, name, assign_to, ignore_permissions=False): +def remove(doctype, name, assign_to): + return _remove(doctype, name, assign_to, ignore_permissions=False) + + +def _remove(doctype, name, assign_to, ignore_permissions=False): return set_status(doctype, name, "", assign_to, status="Cancelled", ignore_permissions=ignore_permissions) @frappe.whitelist() -def remove_multiple(doctype, names, ignore_permissions=False): +def remove_multiple(doctype, names): docname_list = json.loads(names) for name in docname_list: @@ -189,15 +197,15 @@ def remove_multiple(doctype, names, ignore_permissions=False): continue for assignment in assignments: - remove(doctype, name, assignment.get("owner"), ignore_permissions) + remove(doctype, name, assignment.get("owner")) @frappe.whitelist() -def close(doctype: str, name: str, assign_to: str, ignore_permissions=False): +def close(doctype, name, assign_to): if assign_to != frappe.session.user: frappe.throw(_("Only the assignee can complete this to-do.")) - return set_status(doctype, name, "", assign_to, status="Closed", ignore_permissions=ignore_permissions) + return set_status(doctype, name, "", assign_to, status="Closed", ignore_permissions=False) def set_status(doctype, name, todo=None, assign_to=None, status="Cancelled", ignore_permissions=False): diff --git a/frappe/desk/form/document_follow.py b/frappe/desk/form/document_follow.py index c51ac0bea1c5..675a556b2081 100644 --- a/frappe/desk/form/document_follow.py +++ b/frappe/desk/form/document_follow.py @@ -10,11 +10,14 @@ @frappe.whitelist() -def update_follow(doctype: str, doc_name: str, following: bool): +def update_follow(doctype: str, doc_name: str, following: bool | str): + following = frappe.utils.sbool(following) if following: - return (follow_document(doctype, doc_name, frappe.session.user) and True) or False + is_following = follow_document(doctype, doc_name, frappe.session.user) + return bool(is_following) else: - return unfollow_document(doctype, doc_name, frappe.session.user) + unfollow_document(doctype, doc_name, frappe.session.user) + return False @frappe.whitelist() @@ -83,7 +86,7 @@ def unfollow_document(doctype, doc_name, user): if doc: frappe.delete_doc("Document Follow", doc[0].name, force=True) frappe.toast(_("Un-following document {0}").format(doc_name)) - return False + return True return False diff --git a/frappe/desk/form/test_form.py b/frappe/desk/form/test_form.py index 1c7a8f011446..ed695625b4fe 100644 --- a/frappe/desk/form/test_form.py +++ b/frappe/desk/form/test_form.py @@ -3,6 +3,7 @@ import frappe from frappe.desk.form.linked_with import get_linked_docs, get_linked_doctypes +from frappe.desk.form.utils import _sort_field_fallback, get_next from frappe.tests import IntegrationTestCase @@ -11,3 +12,52 @@ def test_linked_with(self): results = get_linked_docs("Role", "System Manager", linkinfo=get_linked_doctypes("Role")) self.assertTrue("User" in results) self.assertTrue("DocType" in results) + + def test_sort_field_fallback(self): + self.assertIsNone(_sort_field_fallback("Note", "name")) + self.assertIsNone(_sort_field_fallback("Note", "creation")) + self.assertIsNone(_sort_field_fallback("Note", "public")) + self.assertEqual(_sort_field_fallback("Note", "expire_notification_on"), "0001-01-01") + self.assertEqual(_sort_field_fallback("Event Notifications", "time"), "00:00:00") + self.assertEqual(_sort_field_fallback("Note", "title"), "") + self.assertEqual(_sort_field_fallback("Note", "nonexistent_field_xyz"), "") + + def test_get_next_with_null_sort_field(self): + notes = [] + for i, expire in enumerate([None, "2099-01-01", None]): + note = frappe.get_doc( + { + "doctype": "Note", + "title": f"test_navigate_null_{frappe.generate_hash(length=8)}_{i}", + "expire_notification_on": expire, + } + ).insert(ignore_permissions=True) + notes.append(note.name) + + try: + filters = {"name": ["in", notes]} + + next_name = get_next( + "Note", + notes[0], + prev=0, + sort_field="expire_notification_on", + sort_order="asc", + filters=filters, + ) + self.assertIsNotNone(next_name) + self.assertIn(next_name, notes) + + prev_name = get_next( + "Note", + notes[1], + prev=1, + sort_field="expire_notification_on", + sort_order="asc", + filters=filters, + ) + self.assertIsNotNone(prev_name) + self.assertIn(prev_name, [notes[0], notes[2]]) + finally: + for name in notes: + frappe.delete_doc("Note", name, force=1, ignore_permissions=True) diff --git a/frappe/desk/form/utils.py b/frappe/desk/form/utils.py index c0c665ed486c..665cc33086dc 100644 --- a/frappe/desk/form/utils.py +++ b/frappe/desk/form/utils.py @@ -10,6 +10,7 @@ from frappe import _ from frappe.core.doctype.file.utils import extract_images_from_html from frappe.desk.form.document_follow import follow_document +from frappe.query_builder.functions import IfNull if TYPE_CHECKING: from frappe.core.doctype.comment.comment import Comment @@ -92,9 +93,15 @@ def get_next( filters = json.loads(filters) table = frappe.qb.DocType(doctype) - sort_column = table[sort_field] name_column = table.name current_sort_value = frappe.db.get_value(doctype, value, sort_field) + fallback = _sort_field_fallback(doctype, sort_field) + if fallback is not None: + sort_column = IfNull(table[sort_field], fallback) + if current_sort_value is None: + current_sort_value = fallback + else: + sort_column = table[sort_field] is_ascending = sort_order.lower() == "asc" if prev == is_ascending: @@ -123,5 +130,22 @@ def get_next( return None +def _sort_field_fallback(doctype: str, fieldname: str): + if fieldname in ("name", "modified", "creation", "modified_by", "owner", "idx", "docstatus"): + return None + df = frappe.get_meta(doctype).get_field(fieldname) + if df is None: + return "" + if df.fieldtype in ("Check", "Float", "Int", "Currency", "Percent"): + return None + if getattr(df, "not_nullable", False): + return None + if df.fieldtype in ("Date", "Datetime"): + return "0001-01-01" + if df.fieldtype == "Time": + return "00:00:00" + return "" + + def get_pdf_link(doctype, docname, print_format="Standard", no_letterhead=0): return f"/api/method/frappe.utils.print_format.download_pdf?doctype={doctype}&name={docname}&format={print_format}&no_letterhead={no_letterhead}" diff --git a/frappe/desk/like.py b/frappe/desk/like.py index 639967369164..16789f80c982 100644 --- a/frappe/desk/like.py +++ b/frappe/desk/like.py @@ -33,6 +33,8 @@ def _toggle_like(doctype, name, add, user=None): if not user: user = frappe.session.user + frappe.has_permission(doctype, "read", doc=name, throw=True) + try: liked_by = frappe.db.get_value(doctype, name, "_liked_by") diff --git a/frappe/desk/link_preview.py b/frappe/desk/link_preview.py index c9143ef5f16a..4d79400b201d 100644 --- a/frappe/desk/link_preview.py +++ b/frappe/desk/link_preview.py @@ -28,7 +28,8 @@ def get_preview_data(doctype, docname): image_field = meta.image_field preview_fields.append(title_field) - preview_fields.append(image_field) + if image_field: + preview_fields.append(image_field) preview_fields.append("name") preview_data = frappe.get_list(doctype, filters={"name": docname}, fields=preview_fields, limit=1) diff --git a/frappe/desk/page/desktop/desktop.html b/frappe/desk/page/desktop/desktop.html index 5d3126af1766..12d7baa7f70e 100644 --- a/frappe/desk/page/desktop/desktop.html +++ b/frappe/desk/page/desktop/desktop.html @@ -8,20 +8,21 @@ alt="{{ _("App Logo") |e }}" > - + {% if (show_search_bar) %} + + {% endif %}
{% if can_write %} @@ -32,7 +31,7 @@
{% let title = frm.get_title(); %} -
+
{%= frappe.utils.escape_html(frappe.utils.html2text(title)) %}
{% if frm.meta.beta %} @@ -41,8 +40,8 @@
{% endif %} {% if (title && title !== frm.doc.name) { %} -
- {%= frm.doc.name %} +
+ {%= frappe.utils.escape_html(frappe.utils.html2text(frm.doc.name)) %}
{% } %}
diff --git a/frappe/public/js/frappe/form/templates/timeline_message_box.html b/frappe/public/js/frappe/form/templates/timeline_message_box.html index ebf8cc5cea5d..650ca40ddab5 100644 --- a/frappe/public/js/frappe/form/templates/timeline_message_box.html +++ b/frappe/public/js/frappe/form/templates/timeline_message_box.html @@ -1,5 +1,6 @@
{% is_mini = frappe.is_mobile() ? true : false %} + {% var show_absolute_datetime = cint(frappe.boot.user.show_absolute_datetime_in_timeline) || cint(frappe.boot.sysdefaults.show_absolute_datetime_in_timeline) %} {% if (doc.communication_type && doc.communication_type == "Automated Message") { %} @@ -24,7 +25,7 @@ {% } %}
- {{ comment_when(doc.communication_date || doc.creation) }} + {{ show_absolute_datetime ? frappe.datetime.str_to_user(doc.communication_date || doc.creation) : comment_when(doc.communication_date || doc.creation) }}
{% } else if (doc.comment_type && doc.comment_type == "Comment") { %} @@ -34,7 +35,7 @@ {{ __("commented") }} · - {{ comment_when(doc.communication_date || doc.creation, is_mini) }} + {{ show_absolute_datetime ? frappe.datetime.str_to_user(doc.communication_date || doc.creation) : comment_when(doc.communication_date || doc.creation, is_mini) }} {% if (doc.published) { %} · @@ -52,7 +53,7 @@ . - {{ comment_when(doc.communication_date || doc.creation, is_mini) }} + {{ show_absolute_datetime ? frappe.datetime.str_to_user(doc.communication_date || doc.creation) : comment_when(doc.communication_date || doc.creation, is_mini) }} {% if (doc.subject) { %}
{{doc.subject}}
diff --git a/frappe/public/js/frappe/form/toolbar.js b/frappe/public/js/frappe/form/toolbar.js index 9196e85027f3..24f34c02c7fb 100644 --- a/frappe/public/js/frappe/form/toolbar.js +++ b/frappe/public/js/frappe/form/toolbar.js @@ -423,7 +423,7 @@ frappe.ui.form.Toolbar = class Toolbar { this.show_jump_to_field_dialog(); }, true, - "Ctrl+J" + { shortcut: "Ctrl+J", ignore_inputs: true } ); } @@ -630,8 +630,9 @@ frappe.ui.form.Toolbar = class Toolbar { ) { let doctype = is_doctype_form ? this.frm.docname : this.frm.doctype; let is_doctype_custom = is_doctype_form ? this.frm.doc.custom : false; + let is_core_doctype = frappe.model.core_doctypes_list.includes(doctype); - if (doctype != "DocType" && !is_doctype_custom && this.frm.meta.issingle === 0) { + if (!is_core_doctype && !is_doctype_custom && this.frm.meta.issingle === 0) { this.page.add_menu_item( __("Customize"), () => { @@ -867,7 +868,11 @@ frappe.ui.form.Toolbar = class Toolbar { !f.df.hidden && f.disp_status !== "None"; - let fields = this.frm.fields + // if a child table row is open in the detail editor, search its fields instead + let grid_row = this.frm.open_grid_row(); + let grid_form = grid_row?.grid_form; + + let fields = (grid_form ? grid_form.layout.fields_list : this.frm.fields) .filter(visible_fields_filter) .map((f) => ({ label: __(f.df.label), value: f.df.fieldname })); @@ -882,10 +887,15 @@ frappe.ui.form.Toolbar = class Toolbar { reqd: 1, }, ], + keep_grid_form_open: !!grid_form, primary_action_label: __("Go"), primary_action: ({ fieldname }) => { dialog.hide(); - this.frm.scroll_to_field(fieldname); + if (grid_form) { + this.scroll_to_grid_field(grid_form, fieldname); + } else { + this.frm.scroll_to_field(fieldname); + } }, animate: false, }); @@ -893,6 +903,50 @@ frappe.ui.form.Toolbar = class Toolbar { dialog.show(); } + scroll_to_grid_field(grid_form, fieldname, focus = true) { + let field = grid_form.fields_dict[fieldname]; + if (!field) return false; + + let $el = field.$wrapper; + if (!$el || !$el.length) return false; + + // set tab as active + if (field.tab && !field.tab.is_active()) { + field.tab.set_active(); + } + + // uncollapse section + if (field.section?.is_collapsed()) { + field.section.collapse(false); + } + + // scroll to input + let scroll_container = grid_form.wrapper.find(".grid-form-body"); + frappe.utils.scroll_to( + $el, + true, + 15, + scroll_container.length ? scroll_container : $(".main-section") + ); + + // focus if text field + if (focus) { + setTimeout(() => { + $el.find("input, select, textarea").focus(); + }, 500); + } + + // highlight control inside field + let control_element = $el.closest(".frappe-control"); + if (control_element.length) { + control_element.addClass("highlight"); + setTimeout(() => { + control_element.removeClass("highlight"); + }, 2000); + } + return true; + } + setup_sidebar_toggle(sidebar_wrapper) { if (frappe.utils.is_xs() || frappe.utils.is_sm()) { this.setup_overlay_sidebar(sidebar_wrapper); @@ -944,7 +998,7 @@ frappe.ui.form.Toolbar = class Toolbar { } get_follow_text(follow) { - if (follow === null) { + if (follow == null) { follow = this.frm.get_docinfo().is_document_followed; } return follow ? __("Unfollow") : __("Follow"); diff --git a/frappe/public/js/frappe/icon_picker/icon_picker.js b/frappe/public/js/frappe/icon_picker/icon_picker.js index 15c25c7f8e10..25860bbcf332 100644 --- a/frappe/public/js/frappe/icon_picker/icon_picker.js +++ b/frappe/public/js/frappe/icon_picker/icon_picker.js @@ -35,63 +35,20 @@ class Picker { } } setup_emojis() { - // setup tab this.setup_tab(); - // setup emoji container this.setup_emoji_container(); - // emojis this.emoji_wrapper = this.icon_picker_wrapper.find(".emojis"); - gemoji.forEach((emoji, i) => { - let $icon = $( - `
${gemoji[i].emoji}
` - ); + frappe.utils.get_emojis().forEach((emoji) => { + const $icon = $(`
${emoji}
`); this.emoji_wrapper.append($icon); - const set_values = () => { - this.set_icon(gemoji[i].emoji); - this.update_icon_selected(); - }; $icon.on("click", () => { - set_values(); + this.set_icon(emoji); + this.update_icon_selected(); }); - // $icon.keydown((e) => { - // const key_code = e.keyCode; - // if ([13, 32].includes(key_code)) { - // e.preventDefault(); - // set_values(); - // } - // }); - }); - this.search_input.on("input", (e) => { - e.preventDefault(); - this.filter_emojis(); }); } filter_emojis() { - let value = this.search_input.val(); - let filtered_emoji_names = []; - if (value) { - gemoji.forEach((g) => { - g.tags.forEach((t) => { - if (t.includes(value)) { - filtered_emoji_names.push(g); - } - }); - g.names.forEach((t) => { - if (t.includes(value)) { - filtered_emoji_names.push(g); - } - }); - }); - } - - if (filtered_emoji_names.length == 0) { - this.emoji_wrapper.find(".emoji-wrapper").removeClass("hidden"); - } else { - this.emoji_wrapper.find(".emoji-wrapper").addClass("hidden"); - filtered_emoji_names.forEach((g) => { - this.emoji_wrapper.find(`.emoji-wrapper[id*='${g.emoji}']`).removeClass("hidden"); - }); - } + this.emoji_wrapper.find(".emoji-wrapper").removeClass("hidden"); } setup_emoji_container() { this.icon_picker_wrapper.find(".icon-section") diff --git a/frappe/public/js/frappe/list/bulk_operations.js b/frappe/public/js/frappe/list/bulk_operations.js index 9738f165dd96..ff2cce5844e7 100644 --- a/frappe/public/js/frappe/list/bulk_operations.js +++ b/frappe/public/js/frappe/list/bulk_operations.js @@ -308,21 +308,30 @@ export default class BulkOperations { } edit(docnames, field_mappings, done) { - let field_options = Object.keys(field_mappings).sort(function (a, b) { + const field_options = Object.keys(field_mappings).sort(function (a, b) { return __(cstr(field_mappings[a].label)).localeCompare( cstr(__(field_mappings[b].label)) ); }); + // Same strings as legacy Select (`options`: sorted mapping keys)—parent `Label (Doctype)`, + // child `Child Label (Table column)`, so labels stay distinguishable after Autocomplete swap. + const field_autocomplete_options = field_options.map((key) => ({ + label: __(cstr(key)), + value: key, + })); const status_regex = /status/i; - const default_field = field_options.find((value) => status_regex.test(value)); + const default_field = + field_options.find((value) => status_regex.test(value)) || + field_options.find((value) => field_mappings[value]?.fieldtype === "Select"); const dialog = new frappe.ui.Dialog({ title: __("Bulk Edit"), fields: [ { - fieldtype: "Select", - options: field_options, + fieldtype: "Autocomplete", + options: field_autocomplete_options, + max_items: Infinity, default: default_field, label: __("Field"), fieldname: "field", @@ -420,6 +429,8 @@ export default class BulkOperations { } function show_help_text() { + if (dialog.get_primary_btn().is(":focus, :active")) return; + let value = dialog.get_value("value"); if (value == null || value === "") { dialog.set_df_property( diff --git a/frappe/public/js/frappe/list/list_view.js b/frappe/public/js/frappe/list/list_view.js index e58a1bf852e4..791b2924ecdd 100644 --- a/frappe/public/js/frappe/list/list_view.js +++ b/frappe/public/js/frappe/list/list_view.js @@ -2003,7 +2003,8 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { if ( frappe.model.can_create("Custom Field") && - frappe.model.can_create("Property Setter") + frappe.model.can_create("Property Setter") && + !frappe.model.core_doctypes_list.includes(doctype) ) { items.push({ label: __("Customize", null, "Button in list view menu"), @@ -2247,21 +2248,14 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { return { label: __("Clear Assignment", null, "Button in list view actions menu"), action: () => { - frappe.confirm( - __("Are you sure you want to clear the assignments?"), - () => { - this.disable_list_update = true; - bulk_operations.clear_assignment(this.get_checked_items(true), () => { - this.disable_list_update = false; - this.clear_checked_items(); - this.refresh(); - }); - }, - () => { + frappe.confirm(__("Are you sure you want to clear the assignments?"), () => { + this.disable_list_update = true; + bulk_operations.clear_assignment(this.get_checked_items(true), () => { + this.disable_list_update = false; this.clear_checked_items(); this.refresh(); - } - ); + }); + }); }, standard: true, }; diff --git a/frappe/public/js/frappe/model/create_new.js b/frappe/public/js/frappe/model/create_new.js index 614ce84b15ff..14f55d192a4e 100644 --- a/frappe/public/js/frappe/model/create_new.js +++ b/frappe/public/js/frappe/model/create_new.js @@ -212,6 +212,12 @@ $.extend(frappe.model, { } else if (df.fieldname === meta.title_field) { // ignore defaults for title field value = ""; + } else if (default_val.startsWith("eval:")) { + try { + value = frappe.utils.eval(default_val.substring(5), { doc }); + } catch (e) { + frappe.throw(__('Invalid "Default" expression')); + } } else { // is this default value is also allowed as per user permissions? var is_allowed_default = diff --git a/frappe/public/js/frappe/ui/dialog.js b/frappe/public/js/frappe/ui/dialog.js index 036dc0c1cd05..4fce520bb671 100644 --- a/frappe/public/js/frappe/ui/dialog.js +++ b/frappe/public/js/frappe/ui/dialog.js @@ -14,7 +14,17 @@ frappe.ui.Dialog = class Dialog extends frappe.ui.FieldGroup { this.is_dialog = true; this.last_focus = null; - $.extend(this, { animate: true, size: null, auto_make: true, centered: false }, opts); + $.extend( + this, + { + animate: true, + size: null, + auto_make: true, + centered: false, + keep_grid_form_open: false, + }, + opts + ); if (this.auto_make) { this.make(); } @@ -94,8 +104,10 @@ frappe.ui.Dialog = class Dialog extends frappe.ui.FieldGroup { me.display = false; me.is_minimized = false; me.hide_scrollbar(false); - // hide any grid row form if open - frappe.ui.form.get_open_grid_form?.()?.hide_form(); + if (!me.keep_grid_form_open) { + // hide any grid row form if open + frappe.ui.form.get_open_grid_form?.()?.hide_form(); + } if (frappe.ui.open_dialogs[frappe.ui.open_dialogs.length - 1] === me) { frappe.ui.open_dialogs.pop(); @@ -131,12 +143,28 @@ frappe.ui.Dialog = class Dialog extends frappe.ui.FieldGroup { }) .on("keydown", function (e) { if (e.key === "Escape" || e.keyCode === 27) { - // when dialog is open and contains an awesomplete dropdown - do not close the dialog on escape key press - if (me.display && me.$wrapper.find(".awesomplete").length) { + // _awesomplete_was_open is set in the capture-phase listener below, before + // Awesomplete's own keydown handler closes the dropdown and clears aria-expanded. + if (me._awesomplete_was_open) { + me._awesomplete_was_open = false; e.stopImmediatePropagation(); } } }); + + // Runs in capture phase, before Awesomplete's bubble-phase keydown handler on the input, + // so aria-expanded is still "true" when the dropdown is open. + me.$wrapper[0].addEventListener( + "keydown", + function (e) { + if (e.key === "Escape" || e.keyCode === 27) { + me._awesomplete_was_open = + me.display && + !!me.$wrapper.find(".awesomplete input[aria-expanded='true']").length; + } + }, + true // capture phase + ); } set_modal_size() { diff --git a/frappe/public/js/frappe/ui/keyboard.js b/frappe/public/js/frappe/ui/keyboard.js index dcb41b1545f8..f398a20ab8f5 100644 --- a/frappe/public/js/frappe/ui/keyboard.js +++ b/frappe/public/js/frappe/ui/keyboard.js @@ -22,6 +22,13 @@ frappe.ui.keys.setup = function () { let standard_shortcuts = []; frappe.ui.keys.standard_shortcuts = standard_shortcuts; +frappe.ui.keys.get_shortcut_label = function (shortcut) { + let label = shortcut.split("+").map(frappe.utils.to_title_case).join("+"); + if (frappe.utils.is_mac()) { + label = label.replace("Ctrl", "⌘").replace("Alt", "⌥"); + } + return label.replace("Shift", "⇧"); +}; frappe.ui.keys.add_shortcut = ({ shortcut, action, @@ -90,22 +97,29 @@ frappe.ui.keys.show_keyboard_shortcut_dialog = () => { if (!shortcuts.length) { return ""; } - let html = shortcuts + let deduped = []; + let seen = {}; + shortcuts .filter((s) => (s.condition ? s.condition() : true)) .filter((s) => !!s.description) - .map((shortcut) => { - let shortcut_label = shortcut.shortcut - .split("+") - .map(frappe.utils.to_title_case) - .join("+"); - if (frappe.utils.is_mac()) { - shortcut_label = shortcut_label.replace("Ctrl", "⌘").replace("Alt", "⌥"); + .forEach((shortcut) => { + if (seen[shortcut.description] !== undefined) { + deduped[seen[shortcut.description]].keys.push(shortcut.shortcut); + } else { + seen[shortcut.description] = deduped.length; + deduped.push({ ...shortcut, keys: [shortcut.shortcut] }); } - - shortcut_label = shortcut_label.replace("Shift", "⇧"); - + }); + let html = deduped + .map((shortcut) => { + let shortcut_label = shortcut.keys + .map((k) => { + let label = frappe.ui.keys.get_shortcut_label(k); + return `${label}`; + }) + .join(" / "); return ` - ${shortcut_label} + ${shortcut_label} ${shortcut.description || ""} `; }) @@ -193,16 +207,14 @@ frappe.ui.keys.add_shortcut({ e.preventDefault(); return false; }, - description: __("Trigger Primary Action"), + description: __("Trigger primary action"), ignore_inputs: true, }); frappe.ui.keys.add_shortcut({ shortcut: "ctrl+k", action: function (e) { - $("#navbar-modal-search").click(); - e.preventDefault(); - return false; + return frappe.search.open_awesomebar_from_global_search_shortcut?.(e); }, description: __("Open Awesomebar"), ignore_inputs: true, @@ -211,38 +223,18 @@ frappe.ui.keys.add_shortcut({ frappe.ui.keys.add_shortcut({ shortcut: "ctrl+g", action: function (e) { - $("#navbar-modal-search").click(); - e.preventDefault(); - return false; + return frappe.search.open_global_search_from_navbar_shortcut?.(e); }, - description: __("Open Awesomebar"), + description: __("Open Global Search"), ignore_inputs: true, }); -frappe.ui.keys.add_shortcut({ - shortcut: "alt+s", - action: function (e) { - e.preventDefault(); - $(".dropdown-navbar-user button").eq(0).click(); - }, - description: __("Open Settings"), -}); - frappe.ui.keys.add_shortcut({ shortcut: "shift+/", action: function () { frappe.ui.keys.show_keyboard_shortcut_dialog(); }, - description: __("Show Keyboard Shortcuts"), -}); - -frappe.ui.keys.add_shortcut({ - shortcut: "alt+h", - action: function (e) { - e.preventDefault(); - $(".dropdown-help button").eq(0).click(); - }, - description: __("Open Help"), + description: __("Show keyboard shortcuts"), }); frappe.ui.keys.on("escape", function (e) { @@ -286,7 +278,7 @@ frappe.ui.keys.add_shortcut({ action: function () { frappe.ui.toolbar.clear_cache(); }, - description: __("Clear Cache and Reload"), + description: __("Clear cache and reload"), }); frappe.ui.keys.key_map = { diff --git a/frappe/public/js/frappe/ui/menu.js b/frappe/public/js/frappe/ui/menu.js index aae005ce72ef..366a5dc6c0c1 100644 --- a/frappe/public/js/frappe/ui/menu.js +++ b/frappe/public/js/frappe/ui/menu.js @@ -103,13 +103,20 @@ frappe.ui.menu = class ContextMenu { item.action ? `return ${item.action}` : "" }"> - +
diff --git a/frappe/public/js/frappe/ui/sidebar/sidebar.js b/frappe/public/js/frappe/ui/sidebar/sidebar.js index 3c8a24430e5f..e679b2b078af 100644 --- a/frappe/public/js/frappe/ui/sidebar/sidebar.js +++ b/frappe/public/js/frappe/ui/sidebar/sidebar.js @@ -21,8 +21,6 @@ frappe.ui.Sidebar = class Sidebar { this.items = []; this.cards = []; this.setup_events(); - this.sidebar_module_map = {}; - this.build_sidebar_module_map(); this.standard_items_setup = false; this.preferred_sidebars = []; } @@ -42,16 +40,6 @@ frappe.ui.Sidebar = class Sidebar { console.log(e); } } - build_sidebar_module_map() { - for (const [key, value] of Object.entries(frappe.boot.workspace_sidebar_item)) { - if (value.module && !value.label.includes("My Workspaces")) { - if (!this.sidebar_module_map[value.module]) { - this.sidebar_module_map[value.module] = []; - } - this.sidebar_module_map[value.module].push(value.label); - } - } - } choose_app_name() { if (frappe.boot.app_name_style === "Default") return; @@ -160,8 +148,8 @@ frappe.ui.Sidebar = class Sidebar { this.promotional_banners.forEach((banner) => { let banner_html = $(` - - ${banner.title} + + ${banner.title} `); @@ -276,6 +264,7 @@ frappe.ui.Sidebar = class Sidebar { this.setup_onboarding(); }); + this.store_last_show_sidebar_for_item(); } add_card(card) { if (this.cards && this.cards.find((i) => i.title === card.title)) return; @@ -298,20 +287,15 @@ frappe.ui.Sidebar = class Sidebar { } setup_events() { const me = this; + this.setup_reload(); frappe.router.on("change", function (router) { - if (frappe.route_options.sidebar) { + if (frappe.route_options && frappe.route_options.sidebar) { frappe.app.sidebar.setup(frappe.route_options.sidebar); frappe.route_options = null; } else { frappe.app.sidebar.set_workspace_sidebar(router); } }); - $(document).on("page-change", function () { - frappe.app.sidebar.toggle(); - }); - $(document).on("form-refresh", function () { - frappe.app.sidebar.toggle(); - }); frappe.ui.keys.add_shortcut({ shortcut: "ctrl+/", @@ -320,13 +304,24 @@ frappe.ui.Sidebar = class Sidebar { }); } - toggle() { + // Fired on page-change / form-refresh. Handles visibility, then runs the + // same resolver as the router so every navigation event picks a sidebar. + // set_workspace_sidebar is idempotent, so re-running it here is a no-op + // unless the route actually warrants a different sidebar. + refresh() { if (!frappe.container.page.page) return; if (frappe.container.page.page.hide_sidebar) { this.wrapper.hide(); + return; + } + this.wrapper.show(); + this.set_workspace_sidebar(); + } + toggle(hide) { + if (hide) { + this.wrapper.hide(); } else { this.wrapper.show(); - this.set_sidebar_for_page(); } } make_dom() { @@ -505,14 +500,6 @@ frappe.ui.Sidebar = class Sidebar { this.setup_notifications(); this.standard_items_setup = true; } - get_workspace_for_module(module) { - for (let i = 0; i < frappe.boot.workspaces.pages.length; i++) { - const workspace = frappe.boot.workspaces.pages[i]; - if (workspace.module == module && !workspace.parent_page) { - return workspace.name; - } - } - } setup_awesomebar() { if (frappe.boot.desk_settings.search_bar) { let awesome_bar = new frappe.search.AwesomeBar(); @@ -560,17 +547,15 @@ frappe.ui.Sidebar = class Sidebar { } expand_sidebar() { - let direction; const is_rtl = frappe.utils.is_rtl(); if (this.sidebar_expanded) { this.wrapper.addClass("expanded"); - direction = is_rtl ? "left" : "right"; $('[data-toggle="tooltip"]').tooltip("dispose"); this.wrapper.find(".avatar-name-email").show(); this.wrapper.find(".onboarding-sidebar span").show(); + this.wrapper.find(".promotional-banner-title").show(); } else { this.wrapper.removeClass("expanded"); - direction = is_rtl ? "right" : "left"; $('[data-toggle="tooltip"]').tooltip({ boundary: "window", container: "body", @@ -578,13 +563,21 @@ frappe.ui.Sidebar = class Sidebar { }); this.wrapper.find(".avatar-name-email").hide(); this.wrapper.find(".onboarding-sidebar span").hide(); + this.wrapper.find(".promotional-banner-title").hide(); } localStorage.setItem("sidebar-expanded", this.sidebar_expanded); + const chevron_icon = this.sidebar_expanded + ? is_rtl + ? "chevron-right" + : "chevron-left" + : is_rtl + ? "chevron-left" + : "chevron-right"; this.wrapper .find(".body-sidebar .collapse-sidebar-link") .find("use") - .attr("href", `#icon-panel-${direction}-open`); + .attr("href", `#icon-${chevron_icon}`); this.sidebar_header.toggle_width(this.sidebar_expanded); $(document).trigger("sidebar-expand", { sidebar_expand: this.sidebar_expanded, @@ -630,114 +623,103 @@ frappe.ui.Sidebar = class Sidebar { set_workspace_sidebar(router) { try { - let route = frappe.get_route(); - let view, entity_name; - switch (route.length) { - case 1: - view = "Page"; - entity_name = route[0]; - break; - case 2: - view = route[0]; - entity_name = route[1]; - - if (frappe.boot.workspace_sidebar_item[entity_name.toLowerCase()]) { - frappe.app.sidebar.setup(entity_name); - return; - } - break; - case 3: - view = route[0]; - entity_name = route[1]; - if (route[0] == "Workspaces" && route[1] == "private") { - entity_name = route[2]; - } - break; - default: - entity_name = route[1]; + const route = frappe.get_route(); + let target; + + if (route.length === 2 && frappe.boot.workspace_sidebar_item[route[1].toLowerCase()]) { + // route points directly at a workspace, e.g. List/ + target = route[1]; + } else { + const entity = this.entity_from_route(route); + const module = router?.meta?.module; + target = this.resolve_sidebar(entity, module); } - let sidebars = this.get_workspace_sidebars(entity_name); - this.preferred_sidebars = sidebars; - let module = router?.meta?.module; - if (this.sidebar_title && sidebars.includes(this.sidebar_title)) { - this.set_active_workspace_item(); - return; + + // only rebuild when the target differs from the current sidebar, so + // this stays a cheap no-op when re-run by page-change / form-refresh + if (target && target !== this.sidebar_title) { + frappe.app.sidebar.setup(target); } + } catch (e) { + console.error(e); + } + + this.set_active_workspace_item(); + } + + entity_from_route(route) { + switch (route.length) { + case 1: + return route[0]; + case 3: + return route[0] === "Workspaces" && route[1] === "private" ? route[2] : route[1]; + default: + return route[0]; + } + } + + // Pick which workspace sidebar to show for the current route. + // Returns a workspace title (or null). Rules are ordered by priority: + // the first one that yields a sidebar wins. + resolve_sidebar(entity, module) { + let candidates = this.get_workspace_sidebars(entity); + this.preferred_sidebars = candidates; + + const remembered = JSON.parse(localStorage.getItem("sidebar_item_map") || "{}"); + + let sidebar_name = null; + + if (this.sidebar_title && candidates.includes(this.sidebar_title)) { + // 1. current sidebar already links to this entity -> keep it + sidebar_name = this.sidebar_title; + } else if (remembered[entity]?.length) { + // 2. previously remembered choice for this entity + sidebar_name = remembered[entity][0]; + } else { + // 3. narrow candidates to the active app if (module) { - sidebars = this.filter_sidebars_from_app( - sidebars, + candidates = this.filter_sidebars_from_app( + candidates, frappe.boot.module_app[module.toLowerCase().replace(/[ -]/g, "_")] ); } - if (sidebars.length == 1) { - frappe.app.sidebar.setup(sidebars[0]); - } else if (sidebars.length > 1) { - let sidebar = this.get_workspace_for_module(module); - if (sidebars.includes(this.get_workspace_for_module(module))) { - frappe.app.sidebar.setup(sidebar); - } + + // 4. resolve by what is left + if (candidates.length === 1) { + sidebar_name = candidates[0]; + } else if (candidates.length > 1) { + sidebar_name = candidates.find((c) => c.toLowerCase() === module?.toLowerCase()); } else if (module) { - this.show_sidebar_for_module(module); + sidebar_name = this.resolve_module_sidebar(module); } - } catch (e) { - console.log(e); } - - this.set_active_workspace_item(); + if (!sidebar_name && candidates.length > 0) { + sidebar_name = candidates[0]; + } + return sidebar_name; } filter_sidebars_from_app(sidebars, app) { let filter_sidebars = []; sidebars.forEach((sidebar) => { - if ( - !filter_sidebars.includes(sidebar) && - frappe.boot.workspace_sidebar_item[sidebar.toLowerCase()].app === app - ) { + const config = frappe.boot.workspace_sidebar_item[sidebar.toLowerCase()]; + if (config && config.app === app && !filter_sidebars.includes(sidebar)) { filter_sidebars.push(sidebar); } }); return filter_sidebars; } + // Public entry point used by page/report views to switch the sidebar + // to the one for a module. Resolution itself lives in resolve_module_sidebar. show_sidebar_for_module(module) { if (this.sidebar_title && this.preferred_sidebars.includes(this.sidebar_title)) { this.set_active_workspace_item(); return; } - if (this.sidebar_fixes && this.sidebar_title != module) return; - let workspace_name = this.get_workspace_for_module(module); - if (frappe.boot.workspace_sidebar_item[module.toLowerCase()]) { - frappe.app.sidebar.setup(module); - } else if ( - workspace_name && - frappe.boot.workspace_sidebar_item[workspace_name.toLowerCase()] - ) { - frappe.app.sidebar.setup(workspace_name); - } else { - let sidebars = - this.sidebar_module_map[module] && - this.sidebar_module_map[module].sort((a, b) => { - return a.localeCompare(b); - }); - if (frappe.get_route()) - if (sidebars && sidebars.length) { - frappe.app.sidebar.setup(sidebars[0]); - } - } + const target = this.resolve_module_sidebar(module); + if (target) frappe.app.sidebar.setup(target); } - set_sidebar_for_page() { - let route = frappe.get_route(); - let views = ["List", "Form", "Workspaces", "query-report"]; - let matches = views.some((view) => route.includes(view)); - if (matches) return; - let workspace_title; - if (route.length == 2) { - workspace_title = this.get_workspace_sidebars(route[1]); - } else { - workspace_title = this.get_workspace_sidebars(route[0]); - } - let module_name = workspace_title[0]; - if (module_name) { - frappe.app.sidebar.setup(module_name || this.sidebar_title); - } + resolve_module_sidebar(module) { + return frappe.boot.workspace_sidebar_item[module.toLowerCase()] ? module : null; } get_workspace_sidebars(link_to) { @@ -752,4 +734,22 @@ frappe.ui.Sidebar = class Sidebar { }); return sidebars; } + setup_reload() { + const me = this; + this.item_sidebar_map = {}; + $(window).on("beforeunload", function () { + me.store_last_show_sidebar_for_item(); + }); + } + store_last_show_sidebar_for_item() { + const me = this; + if (frappe.app.sidebar.active_item) { + let active_item = frappe.app.sidebar.active_item.parent().data("id"); + if (!me.item_sidebar_map[active_item]) { + me.item_sidebar_map[active_item] = []; + } + me.item_sidebar_map[active_item].push(me.sidebar_title); + localStorage.setItem("sidebar_item_map", JSON.stringify(me.item_sidebar_map)); + } + } }; diff --git a/frappe/public/js/frappe/ui/sidebar/sidebar_header.js b/frappe/public/js/frappe/ui/sidebar/sidebar_header.js index d1ade2eb9695..dcc0d91d5370 100644 --- a/frappe/public/js/frappe/ui/sidebar/sidebar_header.js +++ b/frappe/public/js/frappe/ui/sidebar/sidebar_header.js @@ -76,6 +76,7 @@ frappe.ui.SidebarHeader = class SidebarHeader { action: "frappe.ui.toolbar.clear_cache()", is_standard: 1, icon: "rotate-ccw", + shortcut: "Shift+Ctrl+R", }, { name: "help", @@ -213,6 +214,9 @@ frappe.ui.SidebarHeader = class SidebarHeader { name: element.name, label: element.item_label, }; + if (element.action?.includes("frappe.ui.toolbar.show_shortcuts")) { + dropdown_children.shortcut = "Shift+/"; + } if (element.item_type === "Route") { dropdown_children.url = element.route; } @@ -234,6 +238,7 @@ frappe.ui.SidebarHeader = class SidebarHeader { name: "toggle-theme", label: __("Toggle Theme"), icon: is_dark ? "sun" : "moon", + shortcut: "Shift+Ctrl+G", onClick: function () { new frappe.ui.ThemeSwitcher().show(); }, @@ -250,6 +255,7 @@ frappe.ui.SidebarHeader = class SidebarHeader { name: "toggle-sidebar", label: __("Toggle Sidebar"), icon: "panel-right-open", + shortcut: "Ctrl+/", onClick: function () { sidebar.toggle_width(); }, @@ -357,6 +363,13 @@ frappe.ui.SidebarHeader = class SidebarHeader { }
${item.label} + ${ + item.shortcut + ? `${frappe.ui.keys.get_shortcut_label( + item.shortcut + )}` + : "" + }
`).appendTo(this.dropdown_menu); } diff --git a/frappe/public/js/frappe/ui/sidebar/sidebar_item.js b/frappe/public/js/frappe/ui/sidebar/sidebar_item.js index 77d9691e3188..4b745bfbaa4c 100644 --- a/frappe/public/js/frappe/ui/sidebar/sidebar_item.js +++ b/frappe/public/js/frappe/ui/sidebar/sidebar_item.js @@ -33,7 +33,7 @@ frappe.ui.sidebar_item.TypeLink = class SidebarItem { path = frappe.utils.generate_route(args); } else if (this.item.link_type == "Workspace") { let workspaces = frappe.workspaces[frappe.router.slug(this.item.link_to)]; - if (workspaces.public) { + if (workspaces && workspaces.public) { path = "/desk/" + frappe.router.slug(this.item.link_to); } else { path = "/desk/private/" + frappe.router.slug(this.item.link_to); @@ -60,16 +60,29 @@ frappe.ui.sidebar_item.TypeLink = class SidebarItem { let filters_json = JSON.parse( frappe.utils.get_filter_as_json(JSON.parse(this.item.filters)) ); + filters_json = this.transform_filters(filters_json); if (this.item.link_type == "DocType") { args.doc_view = "List"; args.route_options = filters_json; } + } else if (this.item.route_options && this.item.link_type == "DocType") { + args.doc_view = "List"; + args.route_options = JSON.parse(this.item.route_options); } path = frappe.utils.generate_route(args); } } return path; } + transform_filters(filters_json) { + for (const [key, value] of Object.entries(filters_json)) { + if (Array.isArray(value)) { + filters_json[key] = value[1]; + } + } + return filters_json; + } + prepare() {} make() { this.path = this.get_path(); @@ -98,9 +111,7 @@ frappe.ui.sidebar_item.TypeLink = class SidebarItem { } } get_shortcut_html(shortcut) { - if (frappe.utils.is_mac()) { - shortcut = shortcut.replace("Ctrl+", "⌘"); - } + shortcut = frappe.ui.keys.get_shortcut_label(shortcut); return `${shortcut}`; } setup_editing_controls() { diff --git a/frappe/public/js/frappe/ui/toolbar/about.js b/frappe/public/js/frappe/ui/toolbar/about.js index 242546f76262..216c762699be 100644 --- a/frappe/public/js/frappe/ui/toolbar/about.js +++ b/frappe/public/js/frappe/ui/toolbar/about.js @@ -5,88 +5,70 @@ frappe.ui.misc.about = function () { return; } - const dialog = new frappe.ui.Dialog({ title: __("Frappe Framework") }); + const dialog = new frappe.ui.Dialog({ title: __("About") }); + $(dialog.wrapper).addClass("about-dialog"); $(dialog.body).html( - `
-

${__("Open Source Applications for the Web")}

- -

- - ${__("Website")}: - https://frappe.io/ -

- -

- - ${__("Source Code")}: - https://github.com/frappe -

- -

- - ${__("Frappe Blog")}: - https://frappe.io/blog -

- -

- - ${__("Frappe Forum")}: - https://discuss.frappe.io -

- -

- - ${__("LinkedIn")}: - https://linkedin.com/company/frappe-tech -

- -

- - - - X: - https://x.com/frappetech -

- -

- - ${__("YouTube")}: - https://www.youtube.com/@frappetech -

- -

- - ${__("Instagram")}: - https://www.instagram.com/frappetech -

- -
- -
-

${__("Installed Apps")}

- + ` + + + +
+
` ); + $(dialog.footer) + .removeClass("hide") + .prepend( + `` + ); + frappe.ui.misc.about_dialog = dialog; frappe.ui.misc.about_dialog.on_page_show = function () { @@ -102,55 +84,61 @@ frappe.ui.misc.about = function () { } }; - const show_versions = function (versions) { - const $wrap = $("#about-app-versions").empty(); - let app = {}; - - function get_version_text(app) { - if (app.branch) { - return `v${app.branch_version || app.version} (${app.branch})`; - } else { - return `v${app.version}`; - } + const get_version_text = function (app) { + const is_pr_branch = app.branch && /^pr-\d+/i.test(app.branch); + if (app.branch && !is_pr_branch) { + return `${app.version} (${app.branch})`; } + return app.version; + }; - for (const app_name in versions) { - app = versions[app_name]; - const title = `${app_name}: ${app.branch_version || app.version}`; - const text = `

- ${app.title}: ${get_version_text(app)} -

`; - $(text).appendTo($wrap); + const render_app_icon = function (app_name, app) { + const first_letter = (app.title || app_name).charAt(0).toUpperCase(); + if (app.logo) { + return ``; } - - frappe.versions = versions; - - if (frappe.versions) { - $(dialog.body).find("#copy-apps-info").removeClass("hidden"); + if (app.color) { + return `
${first_letter}
`; } + const palette = frappe.get_palette(app_name); + return `
${first_letter}
`; }; - const code_block = (snippet, lang = "") => "```" + lang + "\n" + snippet + "\n```"; - - // Listener for copying installed apps info - $(dialog.body).on("click", "#copy-apps-info", function () { - if (!frappe.versions) return; + const show_versions = function (versions) { + if (versions.frappe) { + $("#about-framework-version").text(`frappe: ${get_version_text(versions.frappe)}`); + } - const versions = Object.entries(frappe.versions).reduce((acc, [key, app]) => { - acc[key] = app.branch_version || app.version; - return acc; - }, {}); + // Show update button on Frappe Cloud sites when updates are available + const $version_row = $("#about-framework-version").closest(".about-info-row"); + $version_row.find(".about-update-indicator").remove(); + if (frappe.boot.has_app_updates && frappe.boot.is_fc_site) { + $(` + ${__("Update Available")} + `).appendTo($version_row); + } - frappe.utils.copy_to_clipboard(code_block(JSON.stringify(versions, null, "\t"), "json")); - }); + const $wrap = $("#about-app-versions").empty(); - // Listener for copy app version - $(dialog.body).on("click", ".app-version", function () { - const title = $(this).attr("title"); - if (title) { - frappe.utils.copy_to_clipboard(title); + for (const app_name in versions) { + if (app_name === "frappe") continue; + const app = versions[app_name]; + const version_text = get_version_text(app); + const title = `${app_name}: ${app.version}`; + + $(`
+ ${render_app_icon(app_name, app)} +
+
${__(app.title)}
+
${app_name}: ${version_text}
+
+
`).appendTo($wrap); } - }); + + frappe.versions = versions; + }; frappe.ui.misc.about_dialog.show(); }; diff --git a/frappe/public/js/frappe/ui/toolbar/awesome_bar.js b/frappe/public/js/frappe/ui/toolbar/awesome_bar.js index 54395363e583..1b15f0957565 100644 --- a/frappe/public/js/frappe/ui/toolbar/awesome_bar.js +++ b/frappe/public/js/frappe/ui/toolbar/awesome_bar.js @@ -52,6 +52,10 @@ frappe.search.AwesomeBar = class AwesomeBar { ${frappe.utils.is_mac() ? "⌘K" : "Ctrl+K"} ${__("to close")} + + ${frappe.utils.is_mac() ? "⌘G" : "Ctrl+G"} + ${__("to open Global Search")} +
`; diff --git a/frappe/public/js/frappe/ui/toolbar/search.html b/frappe/public/js/frappe/ui/toolbar/search.html index 618046149324..976fa18c61c3 100644 --- a/frappe/public/js/frappe/ui/toolbar/search.html +++ b/frappe/public/js/frappe/ui/toolbar/search.html @@ -1,8 +1,9 @@
-