diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000000..e570d9403b --- /dev/null +++ b/.coveragerc @@ -0,0 +1,23 @@ +[run] +omit = + tests/* + .github/* + commands/* + **/test_*.py + +[report] +exclude_lines = + pragma: no cover + if TYPE_CHECKING: + +exclude_also = + def __repr__ + if self.debug: + if settings.DEBUG + raise AssertionError + raise NotImplementedError + if 0: + if __name__ == .__main__.: + if TYPE_CHECKING: + class .*\bProtocol\): + @(abc\.)?abstractmethod diff --git a/.github/helper/db/mariadb.json b/.github/helper/db/mariadb.json index e86e701dc3..0a6c9890c4 100644 --- a/.github/helper/db/mariadb.json +++ b/.github/helper/db/mariadb.json @@ -6,7 +6,8 @@ "allow_tests": true, "db_type": "mariadb", "auto_email_id": "test@example.com", - "mail_server": "smtp.example.com", + "mail_server": "localhost", + "mail_port": 2525, "mail_login": "test@example.com", "mail_password": "test", "admin_password": "admin", diff --git a/.github/helper/db/postgres.json b/.github/helper/db/postgres.json index 6ca83b9e96..f830e717ed 100644 --- a/.github/helper/db/postgres.json +++ b/.github/helper/db/postgres.json @@ -6,7 +6,8 @@ "db_type": "postgres", "allow_tests": true, "auto_email_id": "test@example.com", - "mail_server": "smtp.example.com", + "mail_server": "localhost", + "mail_port": 2525, "mail_login": "test@example.com", "mail_password": "test", "admin_password": "admin", diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 302244280c..8302cc2b26 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -17,7 +17,7 @@ jobs: fetch-depth: 0 persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 18 - name: Setup dependencies diff --git a/.github/workflows/initiate_release.yml b/.github/workflows/initiate_release.yml index ebeef6561c..20bf9967ad 100644 --- a/.github/workflows/initiate_release.yml +++ b/.github/workflows/initiate_release.yml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - version: ["13", "14"] + version: ["13", "14", "15"] steps: - uses: octokit/request-action@v2.x @@ -30,23 +30,3 @@ jobs: head: version-${{ matrix.version }}-hotfix env: GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} - - beta-release: - name: Release - runs-on: ubuntu-latest - strategy: - fail-fast: false - - steps: - - uses: octokit/request-action@v2.x - with: - route: POST /repos/{owner}/{repo}/pulls - owner: frappe - repo: frappe - title: |- - "chore: release v15 beta" - body: "Automated beta release." - base: version-15-beta - head: develop - env: - GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml index 56f540f413..5f2c5cfa29 100644 --- a/.github/workflows/linters.yml +++ b/.github/workflows/linters.yml @@ -21,7 +21,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 200 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version: 18 check-latest: true diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index bb90188c4c..72712c3d5f 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -18,4 +18,4 @@ jobs: with: github-token: ${{ github.token }} issue-inactive-days: 14 - pr-inactive-days: 14 \ No newline at end of file + pr-inactive-days: 14 diff --git a/.github/workflows/on_release.yml b/.github/workflows/on_release.yml index 762d90e8d4..e737c536bf 100644 --- a/.github/workflows/on_release.yml +++ b/.github/workflows/on_release.yml @@ -20,7 +20,7 @@ jobs: with: path: 'frappe' - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version: 18 diff --git a/.github/workflows/patch-mariadb-tests.yml b/.github/workflows/patch-mariadb-tests.yml index 4875e6f5df..d249c4a3b3 100644 --- a/.github/workflows/patch-mariadb-tests.yml +++ b/.github/workflows/patch-mariadb-tests.yml @@ -67,7 +67,7 @@ jobs: python-version: "3.10" - name: Setup Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 18 check-latest: true @@ -134,6 +134,7 @@ jobs: } update_to_version 14 + update_to_version 15 echo "Updating to last commit" pgrep honcho | xargs kill diff --git a/.github/workflows/publish-assets-develop.yml b/.github/workflows/publish-assets-develop.yml index 7975609a55..b3f2c5b0ca 100644 --- a/.github/workflows/publish-assets-develop.yml +++ b/.github/workflows/publish-assets-develop.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@v4 with: path: 'frappe' - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version: 18 - uses: actions/setup-python@v4 diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml index 8533f3cbfe..97000bff15 100644 --- a/.github/workflows/server-tests.yml +++ b/.github/workflows/server-tests.yml @@ -72,6 +72,12 @@ jobs: ports: - 5432:5432 + smtp_server: + image: rnwood/smtp4dev + ports: + - 2525:25 + - 3000:80 + steps: - name: Clone uses: actions/checkout@v4 @@ -89,7 +95,7 @@ jobs: exit 1 fi - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version: 18 check-latest: true @@ -130,12 +136,14 @@ jobs: DB: ${{ matrix.db }} - name: Run Tests - run: cd ~/frappe-bench/sites && ../env/bin/python3 ../apps/frappe/.github/helper/ci.py + run: ../env/bin/python3 ../apps/frappe/.github/helper/ci.py + working-directory: /home/runner/frappe-bench/sites env: SITE: test_site CI_BUILD_ID: ${{ github.run_id }} BUILD_NUMBER: ${{ matrix.container }} TOTAL_BUILDS: 2 + COVERAGE_RCFILE: /home/runner/frappe-bench/apps/frappe/.coveragerc - name: Show bench output if: ${{ always() }} diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index d82d4852b0..00e370e4ed 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -77,7 +77,7 @@ jobs: exit 1 fi - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version: 18 check-latest: true diff --git a/.mergify.yml b/.mergify.yml index 0881dd591b..7c524c5e93 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -9,6 +9,7 @@ pull_request_rules: - author!=frappe-pr-bot - author!=mergify[bot] - or: + - base=version-16 - base=version-15 - base=version-14 - base=version-13 @@ -71,3 +72,13 @@ pull_request_rules: assignees: - "{{ author }}" + - name: backport to version-15-hotfix + conditions: + - label="backport version-15-hotfix" + actions: + backport: + branches: + - version-15-hotfix + assignees: + - "{{ author }}" + diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7443bde6a8..7161bb90ae 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -35,7 +35,7 @@ repos: rev: v2.7.1 hooks: - id: prettier - types_or: [javascript] + types_or: [javascript, vue, scss] # Ignore any files that might contain jinja / bundles exclude: | (?x)^( @@ -44,7 +44,8 @@ repos: .*boilerplate.*| frappe/www/website_script.js| frappe/templates/includes/.*| - frappe/public/js/lib/.* + frappe/public/js/lib/.*| + frappe/website/doctype/website_theme/website_theme_template.scss )$ diff --git a/cypress/integration/control_attach.js b/cypress/integration/control_attach.js index ddaa22884d..70d79855a9 100644 --- a/cypress/integration/control_attach.js +++ b/cypress/integration/control_attach.js @@ -65,6 +65,25 @@ context("Attach Control", () => { //Clicking on the attach button which is displayed as part of creating a doctype with "Attach" fieldtype cy.findByRole("button", { name: "Attach" }).click(); + //Clicking on "Link" button to attach a file using the "Link" button + cy.findByRole("button", { name: "Link" }).click(); + cy.findByPlaceholderText("Attach a web link").type( + "https://wallpaperplay.com/walls/full/8/2/b/72402.jpg", + { force: true } + ); + + //Clicking on the Upload button to upload the file + cy.intercept("POST", "/api/method/upload_file").as("upload_image"); + cy.get(".modal-footer").findByRole("button", { name: "Upload" }).click({ delay: 500 }); + cy.wait("@upload_image"); + cy.findByRole("button", { name: "Save" }).click(); + + //Navigating to the new form for the newly created doctype to check Library button + cy.new_form("Test Attach Control"); + + //Clicking on the attach button which is displayed as part of creating a doctype with "Attach" fieldtype + cy.findByRole("button", { name: "Attach" }).click(); + //Clicking on "Library" button to attach a file using the "Library" button cy.findByRole("button", { name: "Library" }).click(); cy.contains("72402.jpg").click(); @@ -86,9 +105,10 @@ context("Attach Control", () => { //Checking if clicking on the clear button clears the field of the doctype form and again displays the attach button cy.get(".control-input > .btn-sm").should("contain", "Attach"); - //Deleting the doc + //Deleting both docs cy.go_to_list("Test Attach Control"); cy.get(".list-row-checkbox").eq(0).click(); + cy.get(".list-row-checkbox").eq(1).click(); cy.get(".actions-btn-group > .btn").contains("Actions").click(); cy.get('.actions-btn-group > .dropdown-menu [data-label="Delete"]').click(); cy.click_modal_primary_button("Yes"); @@ -107,7 +127,10 @@ context("Attach Control", () => { }; }, }); - cy.get("body").should("have.attr", "data-route", `Form/${doctype}/new-${dt_in_route}-1`); + cy.get("body").should(($body) => { + const dataRoute = $body.attr("data-route"); + expect(dataRoute).to.match(new RegExp(`^Form/${doctype}/new-${dt_in_route}-`)); + }); cy.get("body").should("have.attr", "data-ajax-state", "complete"); //Clicking on the attach button which is displayed as part of creating a doctype with "Attach" fieldtype @@ -127,7 +150,10 @@ context("Attach Control", () => { delete win.navigator.mediaDevices; }, }); - cy.get("body").should("have.attr", "data-route", `Form/${doctype}/new-${dt_in_route}-1`); + cy.get("body").should(($body) => { + const dataRoute = $body.attr("data-route"); + expect(dataRoute).to.match(new RegExp(`^Form/${doctype}/new-${dt_in_route}-`)); + }); cy.get("body").should("have.attr", "data-ajax-state", "complete"); //Clicking on the attach button which is displayed as part of creating a doctype with "Attach" fieldtype @@ -137,3 +163,89 @@ context("Attach Control", () => { cy.findByRole("button", { name: "Camera" }).should("not.exist"); }); }); +context("Attach Control with Failed Document Save", () => { + before(() => { + cy.login(); + cy.visit("/app/doctype"); + return cy + .window() + .its("frappe") + .then((frappe) => { + return frappe.xcall("frappe.tests.ui_test_helpers.create_doctype", { + name: "Test Mandatory Attach Control", + fields: [ + { + label: "Attach File or Image", + fieldname: "attach", + fieldtype: "Attach", + in_list_view: 1, + }, + { + label: "Mandatory Text Field", + fieldname: "text_field", + fieldtype: "Text Editor", + in_list_view: 1, + reqd: 1, + }, + ], + }); + }); + }); + let temp_name = ""; + let docname = ""; + it("Attaching a file on an unsaved document", () => { + //Navigating to the new form for the newly created doctype + cy.new_form("Test Mandatory Attach Control"); + cy.get("body").should(($body) => { + temp_name = $body.attr("data-route").split("/")[2]; + }); + + //Clicking on the attach button which is displayed as part of creating a doctype with "Attach" fieldtype + cy.findByRole("button", { name: "Attach" }).click(); + + //Clicking on "Link" button to attach a file using the "Link" button + cy.findByRole("button", { name: "Link" }).click(); + cy.findByPlaceholderText("Attach a web link").type( + "https://wallpaperplay.com/walls/full/8/2/b/72402.jpg", + { force: true } + ); + + //Clicking on the Upload button to upload the file + cy.intercept("POST", "/api/method/upload_file").as("upload_image"); + cy.get(".modal-footer").findByRole("button", { name: "Upload" }).click({ delay: 500 }); + cy.wait("@upload_image"); + cy.get(".msgprint-dialog .modal-title").contains("Missing Fields").should("be.visible"); + cy.hide_dialog(); + cy.fill_field("text_field", "Random value", "Text Editor").wait(500); + cy.findByRole("button", { name: "Save" }).click().wait(500); + + //Checking if the URL of the attached image is getting displayed in the field of the newly created doctype + cy.get(".attached-file > .ellipsis > .attached-file-link") + .should("have.attr", "href") + .and("equal", "https://wallpaperplay.com/walls/full/8/2/b/72402.jpg"); + + cy.get(".title-text").then(($value) => { + docname = $value.text(); + }); + }); + + it("Check if file was uploaded correctly", () => { + cy.go_to_list("File"); + cy.open_list_filter(); + cy.get(".fieldname-select-area .form-control") + .click() + .type("Attached To Name{enter}") + .blur() + .wait(500); + cy.get('input[data-fieldname="attached_to_name"]').click().type(docname).blur(); + cy.get(".filter-popover .apply-filters").click({ force: true }); + cy.get("header .level-right .list-count").should("contain.text", "1 of 1"); + }); + + it("Check if file exists with temporary name", () => { + cy.open_list_filter(); + cy.get('input[data-fieldname="attached_to_name"]').click().clear().type(temp_name).blur(); + cy.get(".filter-popover .apply-filters").click({ force: true }); + cy.get(".frappe-list > .no-result").should("be.visible"); + }); +}); diff --git a/cypress/integration/control_data.js b/cypress/integration/control_data.js index 4c7ee589ab..019ce68214 100644 --- a/cypress/integration/control_data.js +++ b/cypress/integration/control_data.js @@ -49,7 +49,7 @@ context("Data Control", () => { cy.new_form("Test Data Control"); //Checking the URL for the new form of the doctype - cy.location("pathname").should("eq", "/app/test-data-control/new-test-data-control-1"); + cy.location("pathname").should("contains", "/app/test-data-control/new-test-data-control"); cy.get(".title-text").should("have.text", "New Test Data Control"); cy.get('.frappe-control[data-fieldname="name1"]') .find("label") @@ -128,7 +128,10 @@ context("Data Control", () => { cy.fill_field("phone", "9432380001", "Data"); cy.findByRole("button", { name: "Save" }).click({ force: true }); //Checking if the fields contains the data which has been filled in - cy.location("pathname").should("not.be", "/app/test-data-control/new-test-data-control-1"); + cy.location("pathname").should( + "not.contains", + "/app/test-data-control/new-test-data-control" + ); cy.get_field("name1").should("have.value", "Komal"); cy.get_field("email").should("have.value", "komal@test.com"); cy.get_field("phone").should("have.value", "9432380001"); diff --git a/cypress/integration/dashboard_chart.js b/cypress/integration/dashboard_chart.js index 6023a50abe..f2a837e4b3 100644 --- a/cypress/integration/dashboard_chart.js +++ b/cypress/integration/dashboard_chart.js @@ -5,7 +5,7 @@ context("Dashboard Chart", () => { }); it("Check filter populate for child table doctype", () => { - cy.visit("/app/dashboard-chart/new-dashboard-chart-1"); + cy.new_form("Dashboard Chart"); cy.get('[data-fieldname="parent_document_type"]').should("have.css", "display", "none"); cy.get_field("document_type", "Link"); diff --git a/cypress/integration/form.js b/cypress/integration/form.js index cab2e343c2..facc73f536 100644 --- a/cypress/integration/form.js +++ b/cypress/integration/form.js @@ -35,7 +35,7 @@ context("Form", () => { cy.visit("/app/todo/new"); cy.get_field("description", "Text Editor") .type("this is a test todo", { force: true }) - .wait(200); + .wait(1000); cy.get(".page-title").should("contain", "Not Saved"); cy.intercept({ method: "POST", diff --git a/cypress/integration/form_builder.js b/cypress/integration/form_builder.js index 43cec97a39..b8105d870b 100644 --- a/cypress/integration/form_builder.js +++ b/cypress/integration/form_builder.js @@ -43,7 +43,8 @@ context("Form Builder", () => { // add new section cy.get(first_section).click(15, 10); - cy.get(first_section).find(".section-actions button:first").click(); + cy.get(first_section).find(".dropdown-btn:first").click(); + cy.get(".dropdown-options:visible .dropdown-item:first").click(); // save cy.click_doc_primary_button("Save"); @@ -56,12 +57,17 @@ context("Form Builder", () => { cy.visit(`/app/doctype/${doctype_name}`); cy.findByRole("tab", { name: "Form" }).click(); - let first_field = - ".tab-content.active .section-columns-container:first .column:first .field:first"; + let first_column = ".tab-content.active .section-columns-container:first .column:first"; - cy.get(".fields-container .field[title='Table']").drag(first_field, { - target: { x: 100, y: 10 }, - }); + let last_field = first_column + " .field:last"; + + let add_new_field_btn = first_column + " .add-new-field-btn button"; + + // add new field + cy.get(add_new_field_btn).click(); + + // type table and press enter + cy.get(".combo-box-options:visible .search-box > input").type("table{enter}"); // save cy.click_doc_primary_button("Save"); @@ -70,20 +76,18 @@ context("Form Builder", () => { cy.get_open_dialog().find(".msgprint").should("contain", "Options is required"); cy.hide_dialog(); - cy.get(first_field).click({ force: true }); + cy.get(last_field).click({ force: true }); cy.get(".sidebar-container .frappe-control[data-fieldname='options'] input") .click() .as("input"); cy.get("@input").clear({ force: true }).type("Web Form Field", { delay: 200 }); cy.wait("@search_link"); - cy.get("@input").type("{enter}").blur(); - cy.get(first_field) - .find(".table-controls .table-column") - .contains("Field") - .should("exist"); - cy.get(first_field) + cy.get(last_field).click({ force: true }); + + cy.get(last_field).find(".table-controls .table-column").contains("Field").should("exist"); + cy.get(last_field) .find(".table-controls .table-column") .contains("Fieldtype") .should("exist"); @@ -97,7 +101,7 @@ context("Form Builder", () => { cy.get_open_dialog().find(".msgprint").should("contain", "In List View"); cy.hide_dialog(); - cy.get(first_field).click({ force: true }); + cy.get(last_field).click({ force: true }); cy.get(".sidebar-container .field label .label-area").contains("In List View").click(); // validate In Global Search @@ -181,30 +185,34 @@ context("Form Builder", () => { // add new section cy.get(first_section).click(15, 10); - cy.get(first_section).find(".section-actions button:first").click(); + cy.get(first_section).find(".dropdown-btn:first").click(); + cy.get(".dropdown-options:visible .dropdown-item:first").click(); cy.get(".tab-content.active .form-section-container").should("have.length", 2); // add new column - cy.get(first_section).find(".column:first").click(15, 10); - cy.get(first_section).find(".column:first .column-actions button:first").click(); - cy.get(first_section).find(".column").should("have.length", 3); + cy.get(first_section).click(15, 10); + cy.get(first_section).find(".dropdown-btn:first").click(); + cy.get(".dropdown-options:visible .dropdown-item:last").click(); + cy.get(first_section).find(".column").should("have.length", 2); }); it("Remove Tab/Section/Column", () => { let first_section = ".tab-content.active .form-section-container:first"; // remove column - cy.get(first_section).find(".column:first").click(15, 10); - cy.get(first_section).find(".column:first .column-actions button:last").click(); - cy.get(first_section).find(".column").should("have.length", 2); + cy.get(first_section).click(15, 10); + cy.get(first_section).find(".dropdown-btn:first").click(); + cy.get(".dropdown-options:visible .dropdown-item:last").click(); + cy.get(first_section).find(".column").should("have.length", 1); // remove section cy.get(first_section).click(15, 10); - cy.get(first_section).find(".section-actions button:last").click(); + cy.get(first_section).find(".dropdown-btn:first").click(); + cy.get(".dropdown-options:visible .dropdown-item").eq(1).click(); cy.get(".tab-content.active .form-section-container").should("have.length", 1); // remove tab - cy.get(".tab-header").realHover().find(".tab-actions .remove-tab-btn").click(); + cy.get(".tab-header .tab:last").realHover().find(".remove-tab-btn").click(); cy.get(".tab-header .tabs .tab").should("have.length", 2); }); @@ -230,14 +238,19 @@ context("Form Builder", () => { cy.visit(`/app/doctype/${doctype_name}`); cy.findByRole("tab", { name: "Form" }).click(); - let first_field = - ".tab-content.active .section-columns-container:first .column:first .field:first"; + let first_column = ".tab-content.active .section-columns-container:first .column:first"; - cy.get(".fields-container .field[title='Data']").drag(first_field, { - target: { x: 100, y: 10 }, - }); + let last_field = first_column + " .field:last"; - cy.get(first_field).click(); + let add_new_field_btn = first_column + " .add-new-field-btn button"; + + // add new field + cy.get(add_new_field_btn).click(); + + // type data and press enter + cy.get(".combo-box-options:visible .search-box > input").type("data{enter}"); + + cy.get(last_field).click(); // validate duplicate name cy.get(".sidebar-container .frappe-control[data-fieldname='fieldname'] input") @@ -250,7 +263,7 @@ context("Form Builder", () => { cy.click_doc_primary_button("Save"); cy.get_open_dialog().find(".msgprint").should("contain", "appears multiple times"); cy.hide_dialog(); - cy.get(first_field).click(); + cy.get(last_field).click(); cy.get(".sidebar-container .frappe-control[data-fieldname='fieldname'] input").clear({ force: true, }); diff --git a/cypress/integration/grid_keyboard_shortcut.js b/cypress/integration/grid_keyboard_shortcut.js index 414e822516..1bd11603b6 100644 --- a/cypress/integration/grid_keyboard_shortcut.js +++ b/cypress/integration/grid_keyboard_shortcut.js @@ -1,18 +1,25 @@ context("Grid Keyboard Shortcut", () => { let total_count = 0; + let contact_email_name = null; before(() => { cy.login(); }); beforeEach(() => { cy.reload(); - cy.visit("/app/contact/new-contact-1"); + cy.new_form("Contact"); cy.get('.frappe-control[data-fieldname="email_ids"]').find(".grid-add-row").click(); + // as new names uses hash instead of numbers get row's data-name dynamically. + cy.get('.frappe-control[data-fieldname="email_ids"]') + .find(".grid-body .grid-row") + .should(($row) => { + contact_email_name = $row.attr("data-name"); + }); }); it("Insert new row at the end", () => { cy.add_new_row_in_grid( "{ctrl}{shift}{downarrow}", (cy, total_count) => { - cy.get('[data-name="new-contact-email-1"]').should( + cy.get(`[data-name="${contact_email_name}"]`).should( "have.attr", "data-idx", `${total_count + 1}` @@ -23,17 +30,17 @@ context("Grid Keyboard Shortcut", () => { }); it("Insert new row at the top", () => { cy.add_new_row_in_grid("{ctrl}{shift}{uparrow}", (cy) => { - cy.get('[data-name="new-contact-email-1"]').should("have.attr", "data-idx", "2"); + cy.get(`[data-name="${contact_email_name}"]`).should("have.attr", "data-idx", "2"); }); }); it("Insert new row below", () => { cy.add_new_row_in_grid("{ctrl}{downarrow}", (cy) => { - cy.get('[data-name="new-contact-email-1"]').should("have.attr", "data-idx", "1"); + cy.get(`[data-name^="${contact_email_name}"]`).should("have.attr", "data-idx", "1"); }); }); it("Insert new row above", () => { cy.add_new_row_in_grid("{ctrl}{uparrow}", (cy) => { - cy.get('[data-name="new-contact-email-1"]').should("have.attr", "data-idx", "2"); + cy.get(`[data-name^="${contact_email_name}"]`).should("have.attr", "data-idx", "2"); }); }); }); diff --git a/cypress/integration/number_card.js b/cypress/integration/number_card.js index eb0f19be26..24227fe27b 100644 --- a/cypress/integration/number_card.js +++ b/cypress/integration/number_card.js @@ -5,7 +5,7 @@ context("Number Card", () => { }); it("Check filter populate for child table doctype", () => { - cy.visit("/app/number-card/new-number-card-1"); + cy.new_form("Number Card"); cy.get('[data-fieldname="parent_document_type"]').should("have.css", "display", "none"); cy.get_field("document_type", "Link"); diff --git a/cypress/integration/sidebar.js b/cypress/integration/sidebar.js index 7510425db3..b1a487bc8d 100644 --- a/cypress/integration/sidebar.js +++ b/cypress/integration/sidebar.js @@ -2,7 +2,7 @@ const verify_attachment_visibility = (document, is_private) => { cy.visit(`/app/${document}`); const assertion = is_private ? "be.checked" : "not.be.checked"; - cy.findByRole("button", { name: "Add File" }).click(); + cy.get(".add-attachment-btn").click(); cy.get_open_dialog() .find(".file-upload-area") @@ -27,7 +27,7 @@ const attach_file = (file, no_of_files = 1) => { ); } - cy.findByRole("button", { name: "Add File" }).click(); + cy.get(".add-attachment-btn").click(); cy.get_open_dialog().find(".file-upload-area").selectFile(files, { action: "drag-drop", }); @@ -62,11 +62,8 @@ context("Sidebar", () => { }).then((todo) => { cy.visit(`/app/todo/${todo.message.name}`); - // explore icon btn should be hidden as there are no attachments - cy.get(".explore-btn").should("be.hidden"); - attach_file("cypress/fixtures/sample_image.jpg"); - cy.get(".explore-btn").should("be.visible"); + cy.get(".explore-link").should("be.visible"); cy.get(".show-all-btn").should("be.hidden"); // attach 10 images @@ -75,9 +72,8 @@ context("Sidebar", () => { // attach 1 more image to reach attachment limit attach_file("cypress/fixtures/sample_attachments/attachment-11.txt"); - cy.get(".explore-full-btn").should("be.visible"); - cy.get(".attachments-actions").should("be.hidden"); - cy.get(".explore-btn").should("be.hidden"); + cy.get(".add-attachment-btn").should("be.hidden"); + cy.get(".explore-link").should("be.visible"); // test "Show All" button cy.get(".attachment-row").should("have.length", 10); diff --git a/cypress/integration/timeline.js b/cypress/integration/timeline.js index c6076088fb..f12974d271 100644 --- a/cypress/integration/timeline.js +++ b/cypress/integration/timeline.js @@ -8,7 +8,7 @@ context("Timeline", () => { it("Adding new ToDo, adding new comment, verifying comment addition & deletion and deleting ToDo", () => { //Adding new ToDo - cy.visit("/app/todo/new-todo-1"); + cy.new_form("ToDo"); cy.get('[data-fieldname="description"] .ql-editor.ql-blank') .type("Test ToDo", { force: true }) .wait(200); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 7c287d38cb..a2d0eb2698 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -254,7 +254,10 @@ Cypress.Commands.add("awesomebar", (text) => { Cypress.Commands.add("new_form", (doctype) => { let dt_in_route = doctype.toLowerCase().replace(/ /g, "-"); cy.visit(`/app/${dt_in_route}/new`); - cy.get("body").should("have.attr", "data-route", `Form/${doctype}/new-${dt_in_route}-1`); + cy.get("body").should(($body) => { + const dataRoute = $body.attr("data-route"); + expect(dataRoute).to.match(new RegExp(`^Form/${doctype}/new-${dt_in_route}-`)); + }); cy.get("body").should("have.attr", "data-ajax-state", "complete"); }); diff --git a/esbuild/sass_options.js b/esbuild/sass_options.js index 92b691cb46..1510a856d6 100644 --- a/esbuild/sass_options.js +++ b/esbuild/sass_options.js @@ -1,11 +1,11 @@ let path = require("path"); let { get_app_path, app_list } = require("./utils"); -let node_modules_path = path.resolve(get_app_path("frappe"), "..", "node_modules"); let app_paths = app_list.map(get_app_path).map((app_path) => path.resolve(app_path, "..")); +let node_modules_path = app_paths.map((app_path) => path.resolve(app_path, "node_modules")); module.exports = { - includePaths: [node_modules_path, ...app_paths], + includePaths: [...node_modules_path, ...app_paths], quietDeps: true, importer: function (url) { if (url.startsWith("~")) { diff --git a/frappe/__init__.py b/frappe/__init__.py index e12db352f6..eb9c502905 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -132,16 +132,6 @@ def as_unicode(text: str, encoding: str = "utf-8") -> str: return str(text) -def get_lang_dict(fortype: str, name: str | None = None) -> dict[str, str]: - """Returns the translated language dict for the given type and name. - - :param fortype: must be one of `doctype`, `page`, `report`, `include`, `jsfile`, `boot` - :param name: name of the document for which assets are to be returned.""" - from frappe.translate import get_dict - - return get_dict(fortype, name) - - def set_user_lang(user: str, user_language: str | None = None) -> None: """Guess and set user language for the session. `frappe.local.lang`""" from frappe.translate import get_user_lang @@ -156,6 +146,7 @@ qb = local("qb") conf = local("conf") form = form_dict = local("form_dict") request = local("request") +job = local("job") response = local("response") session = local("session") user = local("user") @@ -169,7 +160,7 @@ lang = local("lang") # This if block is never executed when running the code. It is only used for # telling static code analyzer where to find dynamically defined attributes. -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from werkzeug.wrappers import Request from frappe.database.mariadb.database import MariaDBDatabase @@ -488,9 +479,12 @@ def msgprint( def _raise_exception(): if raise_exception: if inspect.isclass(raise_exception) and issubclass(raise_exception, Exception): - raise raise_exception(msg) + exc = raise_exception(msg) else: - raise ValidationError(msg) + exc = ValidationError(msg) + if out.__frappe_exc_id: + exc.__frappe_exc_id = out.__frappe_exc_id + raise exc if flags.mute_messages: _raise_exception() @@ -527,6 +521,7 @@ def msgprint( if raise_exception: out.raise_exception = 1 + out.__frappe_exc_id = generate_hash() if primary_action: out.primary_action = primary_action @@ -534,11 +529,7 @@ def msgprint( if wide: out.wide = wide - message_log.append(json.dumps(out)) - - if raise_exception and hasattr(raise_exception, "__name__"): - local.response["exc_type"] = raise_exception.__name__ - + message_log.append(out) _raise_exception() @@ -546,8 +537,8 @@ def clear_messages(): local.message_log = [] -def get_message_log(): - return [json.loads(msg_out) for msg_out in local.message_log] +def get_message_log() -> list[dict]: + return [msg_out for msg_out in local.message_log] def clear_last_message(): @@ -1225,7 +1216,7 @@ def get_doc(doctype: str, /) -> _SingleDocument: @overload -def get_doc(doctype: str, name: str, /, for_update: bool | None = None) -> "Document": +def get_doc(doctype: str, name: str, /, *, for_update: bool | None = None) -> "Document": """Retrieve DocType from DB, doctype and name must be positional argument.""" pass @@ -1449,9 +1440,9 @@ def get_site_path(*joins): """Return path of current site. :param *joins: Join additional path elements using `os.path.join`.""" - from os.path import join, normpath + from os.path import join - return normpath(join(local.site_path, *joins)) + return join(local.site_path, *joins) def get_pymodule_path(modulename, *joins): @@ -1713,6 +1704,7 @@ def get_newargs(fn: Callable, kwargs: dict[str, Any]) -> dict[str, Any]: if (a in fnargs) or varkw_exist: newargs[a] = kwargs.get(a) + # WARNING: This behaviour is now part of business logic in places, never remove. newargs.pop("ignore_permissions", None) newargs.pop("flags", None) diff --git a/frappe/api.py b/frappe/api.py deleted file mode 100644 index 084bee060b..0000000000 --- a/frappe/api.py +++ /dev/null @@ -1,306 +0,0 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: MIT. See LICENSE -import base64 -import binascii -import json -from typing import Literal -from urllib.parse import urlencode, urlparse - -import frappe -import frappe.client -import frappe.handler -from frappe import _ -from frappe.utils.data import sbool -from frappe.utils.response import build_response - - -def handle(): - """ - Handler for `/api` methods - - ### Examples: - - `/api/method/{methodname}` will call a whitelisted method - - `/api/resource/{doctype}` will query a table - examples: - - `?fields=["name", "owner"]` - - `?filters=[["Task", "name", "like", "%005"]]` - - `?limit_start=0` - - `?limit_page_length=20` - - `/api/resource/{doctype}/{name}` will point to a resource - `GET` will return doclist - `POST` will insert - `PUT` will update - `DELETE` will delete - - `/api/resource/{doctype}/{name}?run_method={method}` will run a whitelisted controller method - """ - - parts = frappe.request.path[1:].split("/", 3) - call = doctype = name = None - - if len(parts) > 1: - call = parts[1] - - if len(parts) > 2: - doctype = parts[2] - - if len(parts) > 3: - name = parts[3] - - return _RESTAPIHandler(call, doctype, name).get_response() - - -class _RESTAPIHandler: - def __init__(self, call: Literal["method", "resource"], doctype: str | None, name: str | None): - self.call = call - self.doctype = doctype - self.name = name - - def get_response(self): - """Prepare and get response based on URL and form body. - - Note: most methods of this class directly operate on the response local. - """ - match self.call: - case "method": - return self.handle_method() - case "resource": - self.handle_resource() - case _: - raise frappe.DoesNotExistError - - return build_response("json") - - def handle_method(self): - frappe.local.form_dict.cmd = self.doctype - return frappe.handler.handle() - - def handle_resource(self): - if self.doctype and self.name: - self.handle_document_resource() - elif self.doctype: - self.handle_doctype_resource() - else: - raise frappe.DoesNotExistError - - def handle_document_resource(self): - if "run_method" in frappe.local.form_dict: - self.execute_doc_method() - return - - match frappe.local.request.method: - case "GET": - self.get_doc() - case "PUT": - self.update_doc() - case "DELETE": - self.delete_doc() - case _: - raise frappe.DoesNotExistError - - def handle_doctype_resource(self): - match frappe.local.request.method: - case "GET": - self.get_doc_list() - case "POST": - self.create_doc() - case _: - raise frappe.DoesNotExistError - - def execute_doc_method(self): - method = frappe.local.form_dict.pop("run_method") - doc = frappe.get_doc(self.doctype, self.name) - doc.is_whitelisted(method) - - if frappe.local.request.method == "GET": - if not doc.has_permission("read"): - frappe.throw(_("Not permitted"), frappe.PermissionError) - frappe.local.response.update({"data": doc.run_method(method, **frappe.local.form_dict)}) - - elif frappe.local.request.method == "POST": - if not doc.has_permission("write"): - frappe.throw(_("Not permitted"), frappe.PermissionError) - - frappe.local.response.update({"data": doc.run_method(method, **frappe.local.form_dict)}) - frappe.db.commit() - - def get_doc(self): - doc = frappe.get_doc(self.doctype, self.name) - if not doc.has_permission("read"): - raise frappe.PermissionError - doc.apply_fieldlevel_read_permissions() - frappe.local.response.update({"data": doc}) - - def update_doc(self): - data = get_request_form_data() - - doc = frappe.get_doc(self.doctype, self.name, for_update=True) - - if "flags" in data: - del data["flags"] - - # Not checking permissions here because it's checked in doc.save - doc.update(data) - - frappe.local.response.update({"data": doc.save().as_dict()}) - - # check for child table doctype - if doc.get("parenttype"): - frappe.get_doc(doc.parenttype, doc.parent).save() - frappe.db.commit() - - def delete_doc(self): - # Not checking permissions here because it's checked in delete_doc - frappe.delete_doc(self.doctype, self.name, ignore_missing=False) - frappe.local.response.http_status_code = 202 - frappe.local.response.message = "ok" - frappe.db.commit() - - def get_doc_list(self): - if frappe.local.form_dict.get("fields"): - frappe.local.form_dict["fields"] = json.loads(frappe.local.form_dict["fields"]) - - # set limit of records for frappe.get_list - frappe.local.form_dict.setdefault( - "limit_page_length", - frappe.local.form_dict.limit or frappe.local.form_dict.limit_page_length or 20, - ) - - # convert strings to native types - only as_dict and debug accept bool - for param in ["as_dict", "debug"]: - param_val = frappe.local.form_dict.get(param) - if param_val is not None: - frappe.local.form_dict[param] = sbool(param_val) - - # evaluate frappe.get_list - data = frappe.call(frappe.client.get_list, self.doctype, **frappe.local.form_dict) - - # set frappe.get_list result to response - frappe.local.response.update({"data": data}) - - def create_doc(self): - data = get_request_form_data() - data.update({"doctype": self.doctype}) - - # insert document from request data - doc = frappe.get_doc(data).insert() - - # set response data - frappe.local.response.update({"data": doc.as_dict()}) - - # commit for POST requests - frappe.db.commit() - - -def get_request_form_data(): - if frappe.local.form_dict.data is None: - data = frappe.safe_decode(frappe.local.request.get_data()) - else: - data = frappe.local.form_dict.data - - try: - return frappe.parse_json(data) - except ValueError: - return frappe.local.form_dict - - -def validate_auth(): - """ - Authenticate and sets user for the request. - """ - authorization_header = frappe.get_request_header("Authorization", "").split(" ") - - if len(authorization_header) == 2: - validate_oauth(authorization_header) - validate_auth_via_api_keys(authorization_header) - - validate_auth_via_hooks() - - -def validate_oauth(authorization_header): - """ - Authenticate request using OAuth and set session user - - Args: - authorization_header (list of str): The 'Authorization' header containing the prefix and token - """ - - from frappe.integrations.oauth2 import get_oauth_server - from frappe.oauth import get_url_delimiter - - form_dict = frappe.local.form_dict - token = authorization_header[1] - req = frappe.request - parsed_url = urlparse(req.url) - access_token = {"access_token": token} - uri = ( - parsed_url.scheme + "://" + parsed_url.netloc + parsed_url.path + "?" + urlencode(access_token) - ) - http_method = req.method - headers = req.headers - body = req.get_data() - if req.content_type and "multipart/form-data" in req.content_type: - body = None - - try: - required_scopes = frappe.db.get_value("OAuth Bearer Token", token, "scopes").split( - get_url_delimiter() - ) - valid, oauthlib_request = get_oauth_server().verify_request( - uri, http_method, body, headers, required_scopes - ) - if valid: - frappe.set_user(frappe.db.get_value("OAuth Bearer Token", token, "user")) - frappe.local.form_dict = form_dict - except AttributeError: - pass - - -def validate_auth_via_api_keys(authorization_header): - """ - Authenticate request using API keys and set session user - - Args: - authorization_header (list of str): The 'Authorization' header containing the prefix and token - """ - - try: - auth_type, auth_token = authorization_header - authorization_source = frappe.get_request_header("Frappe-Authorization-Source") - if auth_type.lower() == "basic": - api_key, api_secret = frappe.safe_decode(base64.b64decode(auth_token)).split(":") - validate_api_key_secret(api_key, api_secret, authorization_source) - elif auth_type.lower() == "token": - api_key, api_secret = auth_token.split(":") - validate_api_key_secret(api_key, api_secret, authorization_source) - except binascii.Error: - frappe.throw( - _("Failed to decode token, please provide a valid base64-encoded token."), - frappe.InvalidAuthorizationToken, - ) - except (AttributeError, TypeError, ValueError): - pass - - -def validate_api_key_secret(api_key, api_secret, frappe_authorization_source=None): - """frappe_authorization_source to provide api key and secret for a doctype apart from User""" - doctype = frappe_authorization_source or "User" - doc = frappe.db.get_value(doctype=doctype, filters={"api_key": api_key}, fieldname=["name"]) - form_dict = frappe.local.form_dict - doc_secret = frappe.utils.password.get_decrypted_password(doctype, doc, fieldname="api_secret") - if api_secret == doc_secret: - if doctype == "User": - user = frappe.db.get_value(doctype="User", filters={"api_key": api_key}, fieldname=["name"]) - else: - user = frappe.db.get_value(doctype, doc, "user") - if frappe.local.login_manager.user in ("", "Guest"): - frappe.set_user(user) - frappe.local.form_dict = form_dict - - -def validate_auth_via_hooks(): - for auth_hook in frappe.get_hooks("auth_hooks", []): - frappe.get_attr(auth_hook)() diff --git a/frappe/api/__init__.py b/frappe/api/__init__.py new file mode 100644 index 0000000000..5c504b2512 --- /dev/null +++ b/frappe/api/__init__.py @@ -0,0 +1,80 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: MIT. See LICENSE +from enum import Enum + +from werkzeug.exceptions import NotFound +from werkzeug.routing import Map, Submount +from werkzeug.wrappers import Request, Response + +import frappe +import frappe.client +from frappe import _ +from frappe.utils.response import build_response + + +class ApiVersion(str, Enum): + V1 = "v1" + V2 = "v2" + + +def handle(request: Request): + """ + Entry point for `/api` methods. + + APIs are versioned using second part of path. + v1 -> `/api/v1/*` + v2 -> `/api/v2/*` + + Different versions have different specification but broadly following things are supported: + + - `/api/method/{methodname}` will call a whitelisted method + - `/api/resource/{doctype}` will query a table + examples: + - `?fields=["name", "owner"]` + - `?filters=[["Task", "name", "like", "%005"]]` + - `?limit_start=0` + - `?limit_page_length=20` + - `/api/resource/{doctype}/{name}` will point to a resource + `GET` will return document + `POST` will insert + `PUT` will update + `DELETE` will delete + """ + + try: + endpoint, arguments = API_URL_MAP.bind_to_environ(request.environ).match() + except NotFound: # Wrap 404 - backward compatiblity + raise frappe.DoesNotExistError + + data = endpoint(**arguments) + if isinstance(data, Response): + return data + + if data is not None: + frappe.response["data"] = data + return build_response("json") + + +# Merge all API version routing rules +from frappe.api.v1 import url_rules as v1_rules +from frappe.api.v2 import url_rules as v2_rules + +API_URL_MAP = Map( + [ + # V1 routes + Submount("/api", v1_rules), + Submount(f"/api/{ApiVersion.V1.value}", v1_rules), + Submount(f"/api/{ApiVersion.V2.value}", v2_rules), + ], + strict_slashes=False, # Allows skipping trailing slashes + merge_slashes=False, +) + + +def get_api_version() -> ApiVersion | None: + if not frappe.request: + return + + if frappe.request.path.startswith(f"/api/{ApiVersion.V2.value}"): + return ApiVersion.V2 + return ApiVersion.V1 diff --git a/frappe/api/utils.py b/frappe/api/utils.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/api/v1.py b/frappe/api/v1.py new file mode 100644 index 0000000000..d2758f45d5 --- /dev/null +++ b/frappe/api/v1.py @@ -0,0 +1,118 @@ +import json + +from werkzeug.routing import Rule + +import frappe +from frappe import _ +from frappe.utils.data import sbool + + +def document_list(doctype: str): + if frappe.form_dict.get("fields"): + frappe.form_dict["fields"] = json.loads(frappe.form_dict["fields"]) + + # set limit of records for frappe.get_list + frappe.form_dict.setdefault( + "limit_page_length", + frappe.form_dict.limit or frappe.form_dict.limit_page_length or 20, + ) + + # convert strings to native types - only as_dict and debug accept bool + for param in ["as_dict", "debug"]: + param_val = frappe.form_dict.get(param) + if param_val is not None: + frappe.form_dict[param] = sbool(param_val) + + # evaluate frappe.get_list + return frappe.call(frappe.client.get_list, doctype, **frappe.form_dict) + + +def handle_rpc_call(method: str): + import frappe.handler + + method = method.split("/")[0] # for backward compatiblity + + frappe.form_dict.cmd = method + return frappe.handler.handle() + + +def create_doc(doctype: str): + data = get_request_form_data() + data.pop("doctype", None) + return frappe.new_doc(doctype, **data).insert() + + +def update_doc(doctype: str, name: str): + data = get_request_form_data() + + doc = frappe.get_doc(doctype, name, for_update=True) + if "flags" in data: + del data["flags"] + + doc.update(data) + doc.save() + + # check for child table doctype + if doc.get("parenttype"): + frappe.get_doc(doc.parenttype, doc.parent).save() + + return doc + + +def delete_doc(doctype: str, name: str): + # TODO: child doc handling + frappe.delete_doc(doctype, name, ignore_missing=False) + frappe.response.http_status_code = 202 + return "ok" + + +def read_doc(doctype: str, name: str): + # Backward compatiblity + if "run_method" in frappe.form_dict: + return execute_doc_method(doctype, name) + + doc = frappe.get_doc(doctype, name) + if not doc.has_permission("read"): + raise frappe.PermissionError + doc.apply_fieldlevel_read_permissions() + return doc + + +def execute_doc_method(doctype: str, name: str, method: str | None = None): + method = method or frappe.form_dict.pop("run_method") + doc = frappe.get_doc(doctype, name) + doc.is_whitelisted(method) + + if frappe.request.method == "GET": + if not doc.has_permission("read"): + frappe.throw(_("Not permitted"), frappe.PermissionError) + return doc.run_method(method, **frappe.form_dict) + + elif frappe.request.method == "POST": + if not doc.has_permission("write"): + frappe.throw(_("Not permitted"), frappe.PermissionError) + + return doc.run_method(method, **frappe.form_dict) + + +def get_request_form_data(): + if frappe.form_dict.data is None: + data = frappe.safe_decode(frappe.request.get_data()) + else: + data = frappe.form_dict.data + + try: + return frappe.parse_json(data) + except ValueError: + return frappe.form_dict + + +url_rules = [ + Rule("/method/", endpoint=handle_rpc_call), + Rule("/resource/", methods=["GET"], endpoint=document_list), + Rule("/resource/", methods=["POST"], endpoint=create_doc), + Rule("/resource///", methods=["GET"], endpoint=read_doc), + Rule("/resource///", methods=["PUT"], endpoint=update_doc), + Rule("/resource///", methods=["DELETE"], endpoint=delete_doc), + Rule("/resource///", methods=["POST"], endpoint=execute_doc_method), +] diff --git a/frappe/api/v2.py b/frappe/api/v2.py new file mode 100644 index 0000000000..06b6eab04e --- /dev/null +++ b/frappe/api/v2.py @@ -0,0 +1,193 @@ +"""REST API v2 + +This file defines routes and implementation for REST API. + +Note: + - All functions in this file should be treated as "whitelisted" as they are exposed via routes + - None of the functions present here should be called from python code, their location and + internal implementation can change without treating it as "breaking change". +""" +import json +from typing import Any + +from werkzeug.routing import Rule + +import frappe +import frappe.client +from frappe import _, get_newargs, is_whitelisted +from frappe.core.doctype.server_script.server_script_utils import get_server_script_map +from frappe.handler import is_valid_http_method, run_server_script, upload_file + +PERMISSION_MAP = { + "GET": "read", + "POST": "write", +} + + +def handle_rpc_call(method: str, doctype: str | None = None): + from frappe.modules.utils import load_doctype_module + + if doctype: + # Expand to run actual method from doctype controller + module = load_doctype_module(doctype) + method = module.__name__ + "." + method + + for hook in reversed(frappe.get_hooks("override_whitelisted_methods", {}).get(method, [])): + # override using the last hook + method = hook + break + + # via server script + server_script = get_server_script_map().get("_api", {}).get(method) + if server_script: + return run_server_script(server_script) + + try: + method = frappe.get_attr(method) + except Exception as e: + frappe.throw(_("Failed to get method {0} with {1}").format(method, e)) + + is_whitelisted(method) + is_valid_http_method(method) + + return frappe.call(method, **frappe.form_dict) + + +def login(): + """Login happens implicitly, this function doesn't do anything.""" + pass + + +def logout(): + frappe.local.login_manager.logout() + frappe.db.commit() + + +def read_doc(doctype: str, name: str): + doc = frappe.get_doc(doctype, name) + doc.check_permission("read") + doc.apply_fieldlevel_read_permissions() + return doc + + +def document_list(doctype: str): + if frappe.form_dict.get("fields"): + frappe.form_dict["fields"] = json.loads(frappe.form_dict["fields"]) + + # set limit of records for frappe.get_list + frappe.form_dict.limit_page_length = frappe.form_dict.limit or 20 + # evaluate frappe.get_list + return frappe.call(frappe.client.get_list, doctype, **frappe.form_dict) + + +def count(doctype: str) -> int: + from frappe.desk.reportview import get_count + + frappe.form_dict.doctype = doctype + + return get_count() + + +def create_doc(doctype: str): + data = frappe.form_dict + data.pop("doctype", None) + return frappe.new_doc(doctype, **data).insert() + + +def update_doc(doctype: str, name: str): + data = frappe.form_dict + + doc = frappe.get_doc(doctype, name, for_update=True) + data.pop("flags", None) + doc.update(data) + doc.save() + + # check for child table doctype + if doc.get("parenttype"): + frappe.get_doc(doc.parenttype, doc.parent).save() + + return doc + + +def delete_doc(doctype: str, name: str): + frappe.client.delete_doc(doctype, name) + frappe.response.http_status_code = 202 + return "ok" + + +def execute_doc_method(doctype: str, name: str, method: str | None = None): + """Get a document from DB and execute method on it. + + Use cases: + - Submitting/cancelling document + - Triggering some kind of update on a document + """ + method = method or frappe.form_dict.pop("run_method") + doc = frappe.get_doc(doctype, name) + doc.is_whitelisted(method) + + doc.check_permission(PERMISSION_MAP[frappe.request.method]) + return doc.run_method(method, **frappe.form_dict) + + +def run_doc_method(method: str, document: dict[str, Any] | str, kwargs=None): + """run a whitelisted controller method on in-memory document. + + + This is useful for building clients that don't necessarily encode all the business logic but + call server side function on object to validate and modify the doc. + + The doc CAN exists in DB too and can write to DB as well if method is POST. + """ + + if isinstance(document, str): + document = frappe.parse_json(document) + + if kwargs is None: + kwargs = {} + + doc = frappe.get_doc(document) + doc._original_modified = doc.modified + doc.check_if_latest() + + doc.check_permission(PERMISSION_MAP[frappe.request.method]) + + method_obj = getattr(doc, method) + fn = getattr(method_obj, "__func__", method_obj) + is_whitelisted(fn) + is_valid_http_method(fn) + + new_kwargs = get_newargs(fn, kwargs) + response = doc.run_method(method, **new_kwargs) + frappe.response.docs.append(doc) # send modified document and result both. + return response + + +url_rules = [ + # RPC calls + Rule("/method/login", endpoint=login), + Rule("/method/logout", endpoint=logout), + Rule("/method/ping", endpoint=frappe.ping), + Rule("/method/upload_file", endpoint=upload_file), + Rule("/method/", endpoint=handle_rpc_call), + Rule( + "/method/run_doc_method", + methods=["GET", "POST"], + endpoint=lambda: frappe.call(run_doc_method, **frappe.form_dict), + ), + Rule("/method//", endpoint=handle_rpc_call), + # Document level APIs + Rule("/document/", methods=["GET"], endpoint=document_list), + Rule("/document/", methods=["POST"], endpoint=create_doc), + Rule("/document///", methods=["GET"], endpoint=read_doc), + Rule("/document///", methods=["PATCH", "PUT"], endpoint=update_doc), + Rule("/document///", methods=["DELETE"], endpoint=delete_doc), + Rule( + "/document///method//", + methods=["GET", "POST"], + endpoint=execute_doc_method, + ), + # Collection level APIs + Rule("/doctype//meta", methods=["GET"], endpoint=frappe.get_meta), + Rule("/doctype//count", methods=["GET"], endpoint=count), +] diff --git a/frappe/app.py b/frappe/app.py index e2b42ab2a0..c036b65e9c 100644 --- a/frappe/app.py +++ b/frappe/app.py @@ -9,6 +9,7 @@ import re from werkzeug.exceptions import HTTPException, NotFound from werkzeug.middleware.profiler import ProfilerMiddleware +from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.shared_data import SharedDataMiddleware from werkzeug.wrappers import Request, Response from werkzeug.wsgi import ClosingIterator @@ -21,10 +22,11 @@ import frappe.rate_limiter import frappe.recorder import frappe.utils.response from frappe import _ -from frappe.auth import SAFE_HTTP_METHODS, UNSAFE_HTTP_METHODS, HTTPRequest +from frappe.auth import SAFE_HTTP_METHODS, UNSAFE_HTTP_METHODS, HTTPRequest, validate_auth from frappe.middlewares import StaticDataMiddleware -from frappe.utils import CallbackManager, cint, get_site_name, sanitize_html +from frappe.utils import CallbackManager, cint, get_site_name from frappe.utils.data import escape_html +from frappe.utils.deprecations import deprecation_warning from frappe.utils.error import log_error_snapshot from frappe.website.serve import get_response @@ -92,16 +94,20 @@ def application(request: Request): init_request(request) - frappe.api.validate_auth() + validate_auth() if request.method == "OPTIONS": response = Response() elif frappe.form_dict.cmd: - response = frappe.handler.handle() + deprecation_warning( + f"{frappe.form_dict.cmd}: Sending `cmd` for RPC calls is deprecated, call REST API instead `/api/method/cmd`" + ) + frappe.handler.handle() + response = frappe.utils.response.build_response("json") elif request.path.startswith("/api/"): - response = frappe.api.handle() + response = frappe.api.handle(request) elif request.path.startswith("/backups"): response = frappe.utils.response.download_backup(request.path) @@ -277,11 +283,11 @@ def set_cors_headers(response): response.headers.extend(cors_headers) -def make_form_dict(request): +def make_form_dict(request: Request): import json request_data = request.get_data(as_text=True) - if "application/json" in (request.content_type or "") and request_data: + if request_data and request.is_json: args = json.loads(request_data) else: args = {} @@ -293,9 +299,8 @@ def make_form_dict(request): frappe.local.form_dict = frappe._dict(args) - if "_" in frappe.local.form_dict: - # _ is passed by $.ajax so that the request is not cached by the browser. So, remove _ from form_dict - frappe.local.form_dict.pop("_") + # _ is passed by $.ajax so that the request is not cached by the browser. So, remove _ from form_dict + frappe.local.form_dict.pop("_", None) def handle_exception(e): @@ -413,7 +418,13 @@ def sync_database(rollback: bool) -> bool: def serve( - port=8000, profile=False, no_reload=False, no_threading=False, site=None, sites_path="." + port=8000, + profile=False, + no_reload=False, + no_threading=False, + site=None, + sites_path=".", + proxy=False, ): global application, _site, _sites_path _site = site @@ -427,6 +438,9 @@ def serve( if not os.environ.get("NO_STATICS"): application = application_with_statics() + if proxy or os.environ.get("USE_PROXY"): + application = ProxyFix(application, x_for=1, x_proto=1, x_host=1, x_port=1, x_prefix=1) + application.debug = True application.config = {"SERVER_NAME": "127.0.0.1:8000"} diff --git a/frappe/auth.py b/frappe/auth.py index d1259e1aaf..4b53e76533 100644 --- a/frappe/auth.py +++ b/frappe/auth.py @@ -1,6 +1,8 @@ # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # MIT License. See LICENSE -from urllib.parse import quote +import base64 +import binascii +from urllib.parse import quote, urlencode, urlparse import frappe import frappe.database @@ -17,7 +19,8 @@ from frappe.twofactor import ( should_run_2fa, ) from frappe.utils import cint, date_diff, datetime, get_datetime, today -from frappe.utils.password import check_password +from frappe.utils.deprecations import deprecation_warning +from frappe.utils.password import check_password, get_decrypted_password from frappe.website.utils import get_home_page SAFE_HTTP_METHODS = frozenset(("GET", "HEAD", "OPTIONS")) @@ -235,23 +238,28 @@ class LoginManager: _raw_user_name = user user = User.find_by_credentials(user, pwd) + ip_tracker = get_login_attempt_tracker(frappe.local.request_ip) if not user: + ip_tracker and ip_tracker.add_failure_attempt() self.fail("Invalid login credentials", user=_raw_user_name) # Current login flow uses cached credentials for authentication while checking OTP. # Incase of OTP check, tracker for auth needs to be disabled(If not, it can remove tracker history as it is going to succeed anyway) # Tracker is activated for 2FA incase of OTP. ignore_tracker = should_run_2fa(user.name) and ("otp" in frappe.form_dict) - tracker = None if ignore_tracker else get_login_attempt_tracker(user.name) + user_tracker = None if ignore_tracker else get_login_attempt_tracker(user.name) if not user.is_authenticated: - tracker and tracker.add_failure_attempt() + user_tracker and user_tracker.add_failure_attempt() + ip_tracker and ip_tracker.add_failure_attempt() self.fail("Invalid login credentials", user=user.name) elif not (user.name == "Administrator" or user.enabled): - tracker and tracker.add_failure_attempt() + user_tracker and user_tracker.add_failure_attempt() + ip_tracker and ip_tracker.add_failure_attempt() self.fail("User disabled or missing", user=user.name) else: - tracker and tracker.add_success_attempt() + user_tracker and user_tracker.add_success_attempt() + ip_tracker and ip_tracker.add_success_attempt() self.user = user.name def force_user_to_reset_password(self): @@ -433,7 +441,7 @@ def validate_ip_address(user): frappe.throw(_("Access not allowed from this IP Address"), frappe.AuthenticationError) -def get_login_attempt_tracker(user_name: str, raise_locked_exception: bool = True): +def get_login_attempt_tracker(key: str, raise_locked_exception: bool = True): """Get login attempt tracker instance. :param user_name: Name of the loggedin user @@ -447,7 +455,7 @@ def get_login_attempt_tracker(user_name: str, raise_locked_exception: bool = Tru tracker_kwargs["lock_interval"] = sys_settings.allow_login_after_fail tracker_kwargs["max_consecutive_login_attempts"] = sys_settings.allow_consecutive_login_attempts - tracker = LoginAttemptTracker(user_name, **tracker_kwargs) + tracker = LoginAttemptTracker(key, **tracker_kwargs) if raise_locked_exception and track_login_attempts and not tracker.is_user_allowed(): frappe.throw( @@ -466,7 +474,12 @@ class LoginAttemptTracker: """ def __init__( - self, user_name: str, max_consecutive_login_attempts: int = 3, lock_interval: int = 5 * 60 + self, + key: str, + max_consecutive_login_attempts: int = 3, + lock_interval: int = 5 * 60, + *, + user_name: str = None, ): """Initialize the tracker. @@ -474,21 +487,23 @@ class LoginAttemptTracker: :param max_consecutive_login_attempts: Maximum allowed consecutive failed login attempts :param lock_interval: Locking interval incase of maximum failed attempts """ - self.user_name = user_name + if user_name: + deprecation_warning("`username` parameter is deprecated, use `key` instead.") + self.key = key or user_name self.lock_interval = datetime.timedelta(seconds=lock_interval) self.max_failed_logins = max_consecutive_login_attempts @property def login_failed_count(self): - return frappe.cache.hget("login_failed_count", self.user_name) + return frappe.cache.hget("login_failed_count", self.key) @login_failed_count.setter def login_failed_count(self, count): - frappe.cache.hset("login_failed_count", self.user_name, count) + frappe.cache.hset("login_failed_count", self.key, count) @login_failed_count.deleter def login_failed_count(self): - frappe.cache.hdel("login_failed_count", self.user_name) + frappe.cache.hdel("login_failed_count", self.key) @property def login_failed_time(self): @@ -496,15 +511,15 @@ class LoginAttemptTracker: For every user we track only First failed login attempt time within lock interval of time. """ - return frappe.cache.hget("login_failed_time", self.user_name) + return frappe.cache.hget("login_failed_time", self.key) @login_failed_time.setter def login_failed_time(self, timestamp): - frappe.cache.hset("login_failed_time", self.user_name, timestamp) + frappe.cache.hset("login_failed_time", self.key, timestamp) @login_failed_time.deleter def login_failed_time(self): - frappe.cache.hdel("login_failed_time", self.user_name) + frappe.cache.hdel("login_failed_time", self.key) def add_failure_attempt(self): """Log user failure attempts into the system. @@ -547,3 +562,114 @@ class LoginAttemptTracker: ): return False return True + + +def validate_auth(): + """ + Authenticate and sets user for the request. + """ + authorization_header = frappe.get_request_header("Authorization", "").split(" ") + + if len(authorization_header) == 2: + validate_oauth(authorization_header) + validate_auth_via_api_keys(authorization_header) + + # If login via bearer, basic or keypair didn't work then authentication failed and we + # should terminate here. + if frappe.session.user in ("", "Guest"): + raise frappe.AuthenticationError + + validate_auth_via_hooks() + + +def validate_oauth(authorization_header): + """ + Authenticate request using OAuth and set session user + + Args: + authorization_header (list of str): The 'Authorization' header containing the prefix and token + """ + + from frappe.integrations.oauth2 import get_oauth_server + from frappe.oauth import get_url_delimiter + + if authorization_header[0].lower() != "bearer": + return + + form_dict = frappe.local.form_dict + token = authorization_header[1] + req = frappe.request + parsed_url = urlparse(req.url) + access_token = {"access_token": token} + uri = ( + parsed_url.scheme + "://" + parsed_url.netloc + parsed_url.path + "?" + urlencode(access_token) + ) + http_method = req.method + headers = req.headers + body = req.get_data() + if req.content_type and "multipart/form-data" in req.content_type: + body = None + + try: + required_scopes = frappe.db.get_value("OAuth Bearer Token", token, "scopes").split( + get_url_delimiter() + ) + valid, oauthlib_request = get_oauth_server().verify_request( + uri, http_method, body, headers, required_scopes + ) + if valid: + frappe.set_user(frappe.db.get_value("OAuth Bearer Token", token, "user")) + frappe.local.form_dict = form_dict + except AttributeError: + raise frappe.AuthenticationError + + +def validate_auth_via_api_keys(authorization_header): + """ + Authenticate request using API keys and set session user + + Args: + authorization_header (list of str): The 'Authorization' header containing the prefix and token + """ + + try: + auth_type, auth_token = authorization_header + authorization_source = frappe.get_request_header("Frappe-Authorization-Source") + if auth_type.lower() == "basic": + api_key, api_secret = frappe.safe_decode(base64.b64decode(auth_token)).split(":") + validate_api_key_secret(api_key, api_secret, authorization_source) + elif auth_type.lower() == "token": + api_key, api_secret = auth_token.split(":") + validate_api_key_secret(api_key, api_secret, authorization_source) + except binascii.Error: + frappe.throw( + _("Failed to decode token, please provide a valid base64-encoded token."), + frappe.InvalidAuthorizationToken, + ) + except (AttributeError, TypeError, ValueError): + raise frappe.AuthenticationError + + +def validate_api_key_secret(api_key, api_secret, frappe_authorization_source=None): + """frappe_authorization_source to provide api key and secret for a doctype apart from User""" + doctype = frappe_authorization_source or "User" + doc = frappe.db.get_value(doctype=doctype, filters={"api_key": api_key}, fieldname=["name"]) + if not doc: + raise frappe.AuthenticationError + form_dict = frappe.local.form_dict + doc_secret = get_decrypted_password(doctype, doc, fieldname="api_secret") + if api_secret == doc_secret: + if doctype == "User": + user = frappe.db.get_value(doctype="User", filters={"api_key": api_key}, fieldname=["name"]) + else: + user = frappe.db.get_value(doctype, doc, "user") + if frappe.local.login_manager.user in ("", "Guest"): + frappe.set_user(user) + frappe.local.form_dict = form_dict + else: + raise frappe.AuthenticationError + + +def validate_auth_via_hooks(): + for auth_hook in frappe.get_hooks("auth_hooks", []): + frappe.get_attr(auth_hook)() diff --git a/frappe/automation/doctype/assignment_rule/assignment_rule.py b/frappe/automation/doctype/assignment_rule/assignment_rule.py index 24116a0571..4ceff84573 100644 --- a/frappe/automation/doctype/assignment_rule/assignment_rule.py +++ b/frappe/automation/doctype/assignment_rule/assignment_rule.py @@ -78,7 +78,7 @@ class AssignmentRule(Document): def do_assignment(self, doc): # clear existing assignment, to reassign - assign_to.clear(doc.get("doctype"), doc.get("name")) + assign_to.clear(doc.get("doctype"), doc.get("name"), ignore_permissions=True) user = self.get_user(doc) @@ -92,7 +92,8 @@ class AssignmentRule(Document): assignment_rule=self.name, notify=True, date=doc.get(self.due_date_based_on) if self.due_date_based_on else None, - ) + ), + ignore_permissions=True, ) # set for reference in round robin @@ -104,12 +105,14 @@ class AssignmentRule(Document): def clear_assignment(self, doc): """Clear assignments""" if self.safe_eval("unassign_condition", doc): - return assign_to.clear(doc.get("doctype"), doc.get("name")) + return assign_to.clear(doc.get("doctype"), doc.get("name"), ignore_permissions=True) def close_assignments(self, doc): """Close assignments""" if self.safe_eval("close_condition", doc): - return assign_to.close_all_assignments(doc.get("doctype"), doc.get("name")) + return assign_to.close_all_assignments( + doc.get("doctype"), doc.get("name"), ignore_permissions=True + ) def get_user(self, doc): """ diff --git a/frappe/automation/doctype/assignment_rule/test_assignment_rule.py b/frappe/automation/doctype/assignment_rule/test_assignment_rule.py index aadd28fbea..7ae034f896 100644 --- a/frappe/automation/doctype/assignment_rule/test_assignment_rule.py +++ b/frappe/automation/doctype/assignment_rule/test_assignment_rule.py @@ -113,6 +113,20 @@ class TestAutoAssign(FrappeTestCase): len(frappe.get_all("ToDo", dict(allocated_to=user, reference_type=TEST_DOCTYPE))), 10 ) + def test_assingment_on_guest_submissions(self): + """Sometimes documents are inserted as guest, check if assignment rules run on them. Use case: Web Forms""" + with self.set_user("Guest"): + doc = _make_test_record(ignore_permissions=True, public=1) + + # check assignment to *anyone* + self.assertTrue( + frappe.db.get_value( + "ToDo", + {"reference_type": TEST_DOCTYPE, "reference_name": doc.name, "status": "Open"}, + "allocated_to", + ), + ) + def test_based_on_field(self): self.assignment_rule.rule = "Based on Field" self.assignment_rule.field = "owner" @@ -373,13 +387,17 @@ def get_assignment_rule(days, assign=None): return assignment_rule -def _make_test_record(**kwargs): +def _make_test_record( + *, + ignore_permissions=False, + **kwargs, +): doc = frappe.new_doc(TEST_DOCTYPE) if kwargs: doc.update(kwargs) - return doc.insert() + return doc.insert(ignore_permissions=ignore_permissions) def create_test_doctype(doctype: str): diff --git a/frappe/boot.py b/frappe/boot.py index 3f6e3a6a39..c36927637a 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -295,8 +295,7 @@ def add_home_page(bootinfo, docs): docs.append(page) bootinfo["home_page"] = page.name except (frappe.DoesNotExistError, frappe.PermissionError): - if frappe.message_log: - frappe.message_log.pop() + frappe.clear_last_message() bootinfo["home_page"] = "Workspaces" diff --git a/frappe/client.py b/frappe/client.py index 85e99a6534..91f531fe1e 100644 --- a/frappe/client.py +++ b/frappe/client.py @@ -28,12 +28,13 @@ def get_list( doctype, fields=None, filters=None, + group_by=None, order_by=None, limit_start=None, limit_page_length=20, parent=None, - debug=False, - as_dict=True, + debug: bool = False, + as_dict: bool = True, or_filters=None, ): """Returns a list of records by filters, fields, ordering and limit @@ -53,6 +54,7 @@ def get_list( fields=fields, filters=filters, or_filters=or_filters, + group_by=group_by, order_by=order_by, limit_start=limit_start, limit_page_length=limit_page_length, diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index 69438aeca2..715c079af3 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -504,9 +504,9 @@ def postgres(context, extra_args): def _mariadb(extra_args=None): - mysql = which("mysql") + mariadb = which("mariadb") or which("mysql") command = [ - mysql, + mariadb, "--port", str(frappe.conf.db_port), "-u", @@ -521,7 +521,7 @@ def _mariadb(extra_args=None): ] if extra_args: command += list(extra_args) - os.execv(mysql, command) + os.execv(mariadb, command) def _psql(extra_args=None): @@ -929,6 +929,12 @@ def run_ui_tests( @click.command("serve") @click.option("--port", default=8000) @click.option("--profile", is_flag=True, default=False) +@click.option( + "--proxy", + is_flag=True, + default=False, + help="The development server may be run behind a proxy, e.g. ngrok / localtunnel", +) @click.option("--noreload", "no_reload", is_flag=True, default=False) @click.option("--nothreading", "no_threading", is_flag=True, default=False) @click.option("--with-coverage", is_flag=True, default=False) @@ -937,6 +943,7 @@ def serve( context, port=None, profile=False, + proxy=False, no_reload=False, no_threading=False, sites_path=".", @@ -958,6 +965,7 @@ def serve( frappe.app.serve( port=port, profile=profile, + proxy=proxy, no_reload=no_reload, no_threading=no_threading, site=site, diff --git a/frappe/contacts/doctype/address/address.json b/frappe/contacts/doctype/address/address.json index c30299c7ad..0ec67103a7 100644 --- a/frappe/contacts/doctype/address/address.json +++ b/frappe/contacts/doctype/address/address.json @@ -148,7 +148,7 @@ "icon": "fa fa-map-marker", "idx": 5, "links": [], - "modified": "2020-10-21 16:14:37.284830", + "modified": "2023-10-30 05:50:23.912366", "modified_by": "Administrator", "module": "Contacts", "name": "Address", @@ -206,9 +206,19 @@ "role": "System Manager", "share": 1, "write": 1 + }, + { + "create": 1, + "export": 1, + "if_owner": 1, + "print": 1, + "read": 1, + "role": "All", + "write": 1 } ], "search_fields": "country, state", "sort_field": "modified", - "sort_order": "DESC" -} + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/frappe/contacts/doctype/address/address.py b/frappe/contacts/doctype/address/address.py index ea6e9772fd..169c9eecb4 100644 --- a/frappe/contacts/doctype/address/address.py +++ b/frappe/contacts/doctype/address/address.py @@ -1,8 +1,6 @@ # Copyright (c) 2015, Frappe Technologies and contributors # License: MIT. See LICENSE -from typing import Optional - from jinja2 import TemplateSyntaxError import frappe @@ -166,18 +164,23 @@ def get_default_address( @frappe.whitelist() def get_address_display(address_dict: dict | str | None) -> str | None: - if not address_dict: + return render_address(address_dict) + + +def render_address(address: dict | str | None, check_permissions=True) -> str | None: + if not address: return - if not isinstance(address_dict, dict): - address = frappe.get_cached_doc("Address", address_dict) - address.check_permission() - address_dict = address.as_dict() + if not isinstance(address, dict): + address = frappe.get_cached_doc("Address", address) + if check_permissions: + address.check_permission() + address = address.as_dict() - name, template = get_address_templates(address_dict) + name, template = get_address_templates(address) try: - return frappe.render_template(template, address_dict) + return frappe.render_template(template, address) except TemplateSyntaxError: frappe.throw(_("There is an error in your Address Template {0}").format(name)) @@ -258,7 +261,7 @@ def get_company_address(company): if company: ret.company_address = get_default_address("Company", company) - ret.company_address_display = get_address_display(ret.company_address) + ret.company_address_display = render_address(ret.company_address, check_permissions=False) return ret diff --git a/frappe/contacts/doctype/contact/contact.json b/frappe/contacts/doctype/contact/contact.json index 3090746657..679d8b4c8f 100644 --- a/frappe/contacts/doctype/contact/contact.json +++ b/frappe/contacts/doctype/contact/contact.json @@ -257,7 +257,7 @@ "image_field": "image", "index_web_pages_for_search": 1, "links": [], - "modified": "2022-10-27 10:40:50.097481", + "modified": "2023-10-02 12:00:27.299156", "modified_by": "Administrator", "module": "Contacts", "name": "Contact", @@ -381,10 +381,13 @@ "write": 1 }, { - "permlevel": 1, + "create": 1, + "delete": 1, + "if_owner": 1, "read": 1, "report": 1, - "role": "All" + "role": "All", + "write": 1 } ], "show_title_field_in_link": 1, @@ -392,4 +395,4 @@ "sort_order": "ASC", "states": [], "title_field": "full_name" -} +} \ No newline at end of file diff --git a/frappe/core/doctype/activity_log/test_activity_log.py b/frappe/core/doctype/activity_log/test_activity_log.py index 32644d9630..0017d8f870 100644 --- a/frappe/core/doctype/activity_log/test_activity_log.py +++ b/frappe/core/doctype/activity_log/test_activity_log.py @@ -20,6 +20,7 @@ class TestActivityLog(FrappeTestCase): } ) + frappe.local.request_ip = "127.0.0.1" frappe.local.cookie_manager = CookieManager() frappe.local.login_manager = LoginManager() @@ -60,6 +61,7 @@ class TestActivityLog(FrappeTestCase): {"cmd": "login", "sid": "Guest", "pwd": "admin", "usr": "Administrator"} ) + frappe.local.request_ip = "127.0.0.1" frappe.local.cookie_manager = CookieManager() frappe.local.login_manager = LoginManager() diff --git a/frappe/core/doctype/audit_trail/audit_trail.js b/frappe/core/doctype/audit_trail/audit_trail.js index ffd289257e..6fe3e46af4 100644 --- a/frappe/core/doctype/audit_trail/audit_trail.js +++ b/frappe/core/doctype/audit_trail/audit_trail.js @@ -3,6 +3,20 @@ frappe.ui.form.on("Audit Trail", { refresh(frm) { + let prev_route = frappe.get_prev_route(); + if ( + prev_route.length > 2 && + prev_route[0] == "Form" && + !prev_route.includes("Audit Trail") + ) { + frm.set_value("doctype_name", prev_route[1]); + frm.set_value("document", prev_route[2]); + frm.set_value("start_date", ""); + frm.set_value("end_date", ""); + if (frm.doc.doctype_name && frm.doc.document) + frm.events.get_audit_trail_for_document(frm); + } + frm.page.clear_indicator(); frm.disable_save(); @@ -16,17 +30,61 @@ frappe.ui.form.on("Audit Trail", { }; }); + frm.set_query("document", () => { + let filters = { + amended_from: ["!=", ""], + }; + if (frm.doc.start_date && frm.doc.end_date) + filters["creation"] = ["between", [frm.doc.start_date, frm.doc.end_date]]; + else if (frm.doc.start_date) filters["creation"] = [">=", frm.doc.start_date]; + else if (frm.doc.end_date) filters["creation"] = ["<=", frm.doc.end_date]; + return { + filters: filters, + }; + }); + frm.page.set_primary_action("Compare", () => { - frm.call({ - doc: frm.doc, - method: "compare_document", - callback: function (r) { - let document_names = r.message[0]; - let changed_fields = r.message[1]; - frm.events.render_changed_fields(frm, document_names, changed_fields); - frm.events.render_rows_added_or_removed(frm, changed_fields); - }, + frm.events.get_audit_trail_for_document(frm); + }); + }, + + start_date(frm) { + if (frm.doc.start_date > frm.doc.end_date) { + frm.doc.end_date = ""; + frm.refresh_fields(); + } + + frappe.db + .get_value(frm.doc.doctype_name, frm.doc.document, "creation") + .then((creation) => { + if (frappe.datetime.obj_to_str(creation) < frm.doc.start_date) { + frm.doc.document = ""; + frm.refresh_fields(); + } }); + }, + + end_date(frm) { + frappe.db + .get_value(frm.doc.doctype_name, frm.doc.document, "creation") + .then((creation) => { + if (frappe.datetime.obj_to_str(creation) > frm.doc.end_date) { + frm.doc.document = ""; + frm.refresh_fields(); + } + }); + }, + + get_audit_trail_for_document(frm) { + frm.call({ + doc: frm.doc, + method: "compare_document", + callback: function (r) { + let document_names = r.message[0]; + let changed_fields = r.message[1]; + frm.events.render_changed_fields(frm, document_names, changed_fields); + frm.events.render_rows_added_or_removed(frm, changed_fields); + }, }); }, diff --git a/frappe/core/doctype/audit_trail/audit_trail.json b/frappe/core/doctype/audit_trail/audit_trail.json index 8ad5a88c37..762861eedb 100644 --- a/frappe/core/doctype/audit_trail/audit_trail.json +++ b/frappe/core/doctype/audit_trail/audit_trail.json @@ -9,6 +9,10 @@ "doctype_name", "column_break_peck", "document", + "section_break_dfrx", + "start_date", + "column_break_ytzm", + "end_date", "section_break_gppi", "version_table", "rows_added_section", @@ -21,7 +25,7 @@ "fieldname": "doctype_name", "fieldtype": "Link", "in_list_view": 1, - "label": "Doctype", + "label": "DocType", "options": "DocType", "reqd": 1 }, @@ -68,13 +72,34 @@ "fieldtype": "Section Break", "hidden": 1, "label": "Rows Removed" + }, + { + "fieldname": "start_date", + "fieldtype": "Date", + "label": "Start Date" + }, + { + "fieldname": "end_date", + "fieldtype": "Date", + "label": "End Date" + }, + { + "collapsible": 1, + "collapsible_depends_on": "eval: doc.start_date || doc.end_date", + "fieldname": "section_break_dfrx", + "fieldtype": "Section Break", + "label": "Date Range" + }, + { + "fieldname": "column_break_ytzm", + "fieldtype": "Column Break" } ], "hide_toolbar": 1, "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2023-08-22 12:12:59.780845", + "modified": "2023-10-31 13:12:41.749483", "modified_by": "Administrator", "module": "Core", "name": "Audit Trail", @@ -85,7 +110,8 @@ "print": 1, "read": 1, "role": "System Manager", - "share": 1 + "share": 1, + "write": 1 } ], "sort_field": "modified", diff --git a/frappe/core/doctype/audit_trail/audit_trail.py b/frappe/core/doctype/audit_trail/audit_trail.py index b8e4e48b43..2b2e843f46 100644 --- a/frappe/core/doctype/audit_trail/audit_trail.py +++ b/frappe/core/doctype/audit_trail/audit_trail.py @@ -7,6 +7,7 @@ import frappe from frappe import _ from frappe.core.doctype.version.version import get_diff from frappe.model.document import Document +from frappe.utils import compare class AuditTrail(Document): @@ -20,20 +21,31 @@ class AuditTrail(Document): doctype_name: DF.Link document: DF.DynamicLink + end_date: DF.Date | None + start_date: DF.Date | None # end: auto-generated types pass def validate(self): - self.validate_doctype_name() + self.validate_fields() self.validate_document() - def validate_doctype_name(self): - if not self.doctype_name: - frappe.throw(_("{} field cannot be empty.").format(frappe.bold("Doctype"))) + def validate_fields(self): + fields_dict = { + "DocType": self.doctype_name, + "Document": self.document, + } + for field in fields_dict: + if not fields_dict[field]: + frappe.throw(_("{} field cannot be empty.").format(frappe.bold(field))) def validate_document(self): - if not self.document: - frappe.throw(_("{} field cannot be empty.").format(frappe.bold("Document"))) + if not frappe.db.exists(self.doctype_name, self.document): + frappe.throw( + _("The selected document {0} is not a {1}.").format( + frappe.bold(self.document), frappe.bold(self.doctype_name) + ) + ) @frappe.whitelist() def compare_document(self): @@ -58,11 +70,18 @@ class AuditTrail(Document): } def get_amended_documents(self): + start_date = self.get("start_date") amended_document_names = [] curr_doc = self.document - while curr_doc and len(amended_document_names) < 5: + creation = frappe.db.get_value(self.doctype_name, self.document, "creation") + while ( + curr_doc + and len(amended_document_names) < 5 + and (start_date is None or compare(creation, ">=", start_date, "Date")) + ): amended_document_names.append(curr_doc) curr_doc = frappe.db.get_value(self.doctype_name, curr_doc, "amended_from") + creation = frappe.db.get_value(self.doctype_name, curr_doc, "creation") amended_document_names = amended_document_names[::-1] return amended_document_names diff --git a/frappe/core/doctype/audit_trail/test_audit_trail.py b/frappe/core/doctype/audit_trail/test_audit_trail.py index 45093de033..c5f195a9f6 100644 --- a/frappe/core/doctype/audit_trail/test_audit_trail.py +++ b/frappe/core/doctype/audit_trail/test_audit_trail.py @@ -3,6 +3,7 @@ import frappe from frappe.tests.utils import FrappeTestCase +from frappe.utils import today class TestAuditTrail(FrappeTestCase): @@ -129,6 +130,11 @@ def amend_document(amend_from, changed_fields, rows_updated, submit=False): def create_comparator_doc(doctype_name, document): comparator = frappe.new_doc("Audit Trail") - comparator.doctype_name = doctype_name - comparator.document = document + args_dict = { + "doctype_name": doctype_name, + "document": document, + "start_date": today(), + "end_date": today(), + } + comparator.update(args_dict) return comparator diff --git a/frappe/core/doctype/communication/communication.py b/frappe/core/doctype/communication/communication.py index 9b42c431b2..0e089edb6c 100644 --- a/frappe/core/doctype/communication/communication.py +++ b/frappe/core/doctype/communication/communication.py @@ -463,7 +463,7 @@ class Communication(Document, CommunicationEmailMixin): duplicate = True if duplicate: - del self.timeline_links[:] # make it python 2 compatible as list.clear() is python 3 only + self.timeline_links.clear() for l in links: self.add_link(link_doctype=l[0], link_name=l[1]) diff --git a/frappe/core/doctype/data_import/patches/__init__.py b/frappe/core/doctype/data_import/patches/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/core/doctype/data_import/patches/remove_stale_docfields_from_legacy_version.py b/frappe/core/doctype/data_import/patches/remove_stale_docfields_from_legacy_version.py new file mode 100644 index 0000000000..91d9c8f641 --- /dev/null +++ b/frappe/core/doctype/data_import/patches/remove_stale_docfields_from_legacy_version.py @@ -0,0 +1,6 @@ +import frappe + + +def execute(): + """Remove stale docfields from legacy version""" + frappe.db.delete("DocField", {"options": "Data Import", "parent": "Data Import Legacy"}) diff --git a/frappe/core/doctype/data_import/test_importer.py b/frappe/core/doctype/data_import/test_importer.py index 978f5792dd..965e34c3e6 100644 --- a/frappe/core/doctype/data_import/test_importer.py +++ b/frappe/core/doctype/data_import/test_importer.py @@ -63,7 +63,7 @@ class TestImporter(FrappeTestCase): def test_data_import_without_mandatory_values(self): import_file = get_import_file("sample_import_file_without_mandatory") data_import = self.get_importer(doctype_name, import_file) - frappe.local.message_log = [] + frappe.clear_messages() data_import.start_import() data_import.reload() diff --git a/frappe/core/doctype/deleted_document/deleted_document.py b/frappe/core/doctype/deleted_document/deleted_document.py index b5b35206e9..aa6239c279 100644 --- a/frappe/core/doctype/deleted_document/deleted_document.py +++ b/frappe/core/doctype/deleted_document/deleted_document.py @@ -73,11 +73,11 @@ def bulk_restore(docnames): restored.append(d) except frappe.DocumentAlreadyRestored: - frappe.message_log.pop() + frappe.clear_last_message() invalid.append(d) except Exception: - frappe.message_log.pop() + frappe.clear_last_message() failed.append(d) frappe.db.rollback() diff --git a/frappe/core/doctype/docfield/docfield.json b/frappe/core/doctype/docfield/docfield.json index f297e0dbe6..42e8d21e34 100644 --- a/frappe/core/doctype/docfield/docfield.json +++ b/frappe/core/doctype/docfield/docfield.json @@ -244,9 +244,10 @@ }, { "default": "0", + "description": "If unchecked, the value will always be re-fetched on save.", "fieldname": "fetch_if_empty", "fieldtype": "Check", - "label": "Fetch only if value is not set" + "label": "Fetch on Save if Empty" }, { "fieldname": "permissions", @@ -415,7 +416,7 @@ "width": "50px" }, { - "description": "Number of columns for a field in a List View or a Grid (Total Columns should be less than 11)", + "description": "Number of columns for a field in a grid (total columns should be less than 11)", "fieldname": "columns", "fieldtype": "Int", "label": "Columns" @@ -565,7 +566,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2023-06-08 19:05:10.778371", + "modified": "2023-10-25 06:53:45.194081", "modified_by": "Administrator", "module": "Core", "name": "DocField", diff --git a/frappe/core/doctype/doctype/doctype.js b/frappe/core/doctype/doctype/doctype.js index 650729aef6..c21654a109 100644 --- a/frappe/core/doctype/doctype/doctype.js +++ b/frappe/core/doctype/doctype/doctype.js @@ -2,6 +2,12 @@ // MIT License. See license.txt frappe.ui.form.on("DocType", { + onload: function (frm) { + if (frm.is_new()) { + frappe.listview_settings["DocType"].new_doctype_dialog(); + } + }, + before_save: function (frm) { let form_builder = frappe.form_builder; if (form_builder?.store) { @@ -13,6 +19,7 @@ frappe.ui.form.on("DocType", { } } }, + after_save: function (frm) { if ( frappe.form_builder && @@ -22,6 +29,7 @@ frappe.ui.form.on("DocType", { frappe.form_builder.store.fetch(); } }, + refresh: function (frm) { frm.set_query("role", "permissions", function (doc) { if (doc.custom && frappe.session.user != "Administrator") { @@ -119,6 +127,20 @@ frappe.ui.form.on("DocType", { setup_default_views: (frm) => { frappe.model.set_default_views_for_doctype(frm.doc.name, frm); }, + + on_tab_change: (frm) => { + let current_tab = frm.get_active_tab().label; + + if (current_tab === "Form") { + frm.footer.wrapper.hide(); + frm.form_wrapper.find(".form-message").hide(); + frm.form_wrapper.addClass("mb-1"); + } else { + frm.footer.wrapper.show(); + frm.form_wrapper.find(".form-message").show(); + frm.form_wrapper.removeClass("mb-1"); + } + }, }); frappe.ui.form.on("DocField", { @@ -135,33 +157,6 @@ frappe.ui.form.on("DocField", { }, }); -function render_form_builder_message(frm) { - $(frm.fields_dict["try_form_builder_html"].wrapper).empty(); - if (!frm.is_new() && frm.fields_dict["try_form_builder_html"]) { - let title = __("Use Form Builder to visually edit your form layout"); - let msg = __( - "You can drag and drop fields to create your form layout, add tabs, sections and columns to organize your form and update field properties all from one screen." - ); - - let message = ` - - `; - - $(frm.fields_dict["try_form_builder_html"].wrapper).html(message); - } -} - function render_form_builder(frm) { if (frappe.form_builder && frappe.form_builder.doctype === frm.doc.name) { frappe.form_builder.setup_page_actions(); diff --git a/frappe/core/doctype/doctype/doctype.json b/frappe/core/doctype/doctype/doctype.json index 21d5fbfac8..082471da7d 100644 --- a/frappe/core/doctype/doctype/doctype.json +++ b/frappe/core/doctype/doctype/doctype.json @@ -8,6 +8,9 @@ "document_type": "Document", "engine": "InnoDB", "field_order": [ + "form_builder_tab", + "form_builder", + "settings_tab", "sb0", "module", "is_submittable", @@ -32,32 +35,6 @@ "column_break_15", "description", "documentation", - "sb2", - "permissions", - "restrict_to_domain", - "read_only", - "in_create", - "actions_section", - "actions", - "links_section", - "links", - "document_states_section", - "states", - "web_view", - "has_web_view", - "allow_guest_to_view", - "index_web_pages_for_search", - "route", - "is_published_field", - "website_search_field", - "advanced", - "engine", - "migration_hash", - "form_builder_tab", - "form_builder", - "fields_section", - "fields", - "settings_tab", "form_settings_section", "image_field", "timeline_field", @@ -92,6 +69,29 @@ "email_append_to", "sender_field", "subject_field", + "sb2", + "permissions", + "restrict_to_domain", + "read_only", + "in_create", + "actions_section", + "actions", + "links_section", + "links", + "document_states_section", + "states", + "web_view", + "has_web_view", + "allow_guest_to_view", + "index_web_pages_for_search", + "route", + "is_published_field", + "website_search_field", + "advanced", + "engine", + "migration_hash", + "fields_section", + "fields", "connections_tab" ], "fields": [ @@ -640,6 +640,7 @@ "label": "Settings" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "form_builder_tab", "fieldtype": "Tab Break", "label": "Form" @@ -742,7 +743,7 @@ "link_fieldname": "reference_doctype" } ], - "modified": "2023-08-29 12:27:06.587523", + "modified": "2023-11-01 16:45:14.960949", "modified_by": "Administrator", "module": "Core", "name": "DocType", diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index a02f776188..7b5c58dedd 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -16,6 +16,7 @@ from frappe import _ from frappe.cache_manager import clear_controller_cache, clear_user_cache from frappe.custom.doctype.custom_field.custom_field import create_custom_field from frappe.custom.doctype.property_setter.property_setter import make_property_setter +from frappe.database import savepoint from frappe.database.schema import validate_column_length, validate_column_name from frappe.desk.notifications import delete_notification_count_for, get_filters_for from frappe.desk.utils import validate_route_conflict @@ -349,8 +350,10 @@ class DocType(Document): self.flags.update_fields_to_fetch_queries = [] - if set(old_fields_to_fetch) != {df.fieldname for df in new_meta.get_fields_to_fetch()}: - for df in new_meta.get_fields_to_fetch(): + new_fields_to_fetch = new_meta.get_fields_to_fetch() + + if set(old_fields_to_fetch) != {df.fieldname for df in new_fields_to_fetch}: + for df in new_fields_to_fetch: if df.fieldname not in old_fields_to_fetch: link_fieldname, source_fieldname = df.fetch_from.split(".", 1) link_df = new_meta.get_field(link_fieldname) @@ -522,7 +525,9 @@ class DocType(Document): if self.flags.in_insert: self.run_module_method("after_doctype_insert") + self.sync_doctype_layouts() delete_notification_count_for(doctype=self.name) + frappe.clear_cache(doctype=self.name) # clear user cache so that on the next reload this doctype is included in boot @@ -533,6 +538,17 @@ class DocType(Document): clear_linked_doctype_cache() + @savepoint(catch=Exception) + def sync_doctype_layouts(self): + """Sync Doctype Layout""" + doctype_layouts = frappe.get_all( + "DocType Layout", filters={"document_type": self.name}, pluck="name", ignore_ddl=True + ) + for layout in doctype_layouts: + layout_doc = frappe.get_doc("DocType Layout", layout) + layout_doc.sync_fields() + layout_doc.save() + def setup_autoincrement_and_sequence(self): """Changes name type and makes sequence on change (if required)""" @@ -854,6 +870,7 @@ class DocType(Document): "read_only": 1, "print_hide": 1, "no_copy": 1, + "search_index": 1, }, ) diff --git a/frappe/core/doctype/doctype/doctype_list.js b/frappe/core/doctype/doctype/doctype_list.js new file mode 100644 index 0000000000..963e863380 --- /dev/null +++ b/frappe/core/doctype/doctype/doctype_list.js @@ -0,0 +1,122 @@ +frappe.listview_settings["DocType"] = { + primary_action: function () { + this.new_doctype_dialog(); + }, + + new_doctype_dialog() { + let non_developer = frappe.session.user !== "Administrator" || !frappe.boot.developer_mode; + let fields = [ + { + label: __("DocType Name"), + fieldname: "name", + fieldtype: "Data", + reqd: 1, + }, + { fieldtype: "Column Break" }, + { + label: __("Module"), + fieldname: "module", + fieldtype: "Link", + options: "Module Def", + reqd: 1, + }, + { fieldtype: "Section Break" }, + { + label: __("Is Submittable"), + fieldname: "is_submittable", + fieldtype: "Check", + description: __( + "Once submitted, submittable documents cannot be changed. They can only be Cancelled and Amended." + ), + depends_on: "eval:!doc.istable && !doc.issingle", + }, + { + label: __("Is Child Table"), + fieldname: "istable", + fieldtype: "Check", + description: __("Child Tables are shown as a Grid in other DocTypes"), + depends_on: "eval:!doc.is_submittable && !doc.issingle", + }, + { + label: __("Editable Grid"), + fieldname: "editable_grid", + fieldtype: "Check", + depends_on: "istable", + default: 1, + }, + { + label: __("Is Single"), + fieldname: "issingle", + fieldtype: "Check", + description: __( + "Single Types have only one record no tables associated. Values are stored in tabSingles" + ), + depends_on: "eval:!doc.istable && !doc.is_submittable", + }, + { + label: "Is Tree", + fieldname: "is_tree", + fieldtype: "Check", + default: "0", + depends_on: "eval:!doc.istable", + description: "Tree structures are implemented using Nested Set", + }, + { + label: __("Custom?"), + fieldname: "custom", + fieldtype: "Check", + default: non_developer, + read_only: non_developer, + }, + ]; + + if (!non_developer) { + fields.push({ + label: "Is Virtual", + fieldname: "is_virtual", + fieldtype: "Check", + default: "0", + }); + } + + let new_d = new frappe.ui.Dialog({ + title: __("Create New DocType"), + fields: fields, + primary_action_label: __("Create & Continue"), + primary_action(values) { + if (!values.istable) values.editable_grid = 0; + frappe.db + .insert({ + doctype: "DocType", + ...values, + permissions: [ + { + create: 1, + delete: 1, + email: 1, + export: 1, + print: 1, + read: 1, + report: 1, + role: "System Manager", + share: 1, + write: 1, + }, + ], + fields: [{ fieldtype: "Section Break" }], + }) + .then((doc) => { + frappe.set_route("Form", "DocType", doc.name); + }); + }, + secondary_action_label: __("Cancel"), + secondary_action() { + new_d.hide(); + if (frappe.get_route()[0] === "Form") { + frappe.set_route("List", "DocType"); + } + }, + }); + new_d.show(); + }, +}; diff --git a/frappe/core/doctype/document_naming_settings/document_naming_settings.py b/frappe/core/doctype/document_naming_settings/document_naming_settings.py index 3ec4147ec7..ddb25dd262 100644 --- a/frappe/core/doctype/document_naming_settings/document_naming_settings.py +++ b/frappe/core/doctype/document_naming_settings/document_naming_settings.py @@ -248,8 +248,7 @@ class DocumentNamingSettings(Document): doc = self._fetch_last_doc_if_available() return "\n".join(NamingSeries(series).get_preview(doc=doc)) except Exception as e: - if frappe.message_log: - frappe.message_log.pop() + frappe.clear_last_message() return _("Failed to generate names from the series") + f"\n{str(e)}" def _fetch_last_doc_if_available(self): diff --git a/frappe/core/doctype/file/file.js b/frappe/core/doctype/file/file.js index 117606d854..b7838b446a 100644 --- a/frappe/core/doctype/file/file.js +++ b/frappe/core/doctype/file/file.js @@ -40,14 +40,14 @@ frappe.ui.form.on("File", { $preview = $(`
`); } else if (frappe.utils.is_video_file(frm.doc.file_url)) { $preview = $(`
`); @@ -58,14 +58,14 @@ frappe.ui.form.on("File", { style="background:#323639;" width="100%" height="1190" - src="${frm.doc.file_url}" type="application/pdf" + src="${frappe.utils.escape_html(frm.doc.file_url)}" type="application/pdf" > `); } else if (file_extension === "mp3") { $preview = $(`
`); diff --git a/frappe/core/doctype/file/test_file.py b/frappe/core/doctype/file/test_file.py index 42f349aef4..43dc51c8b1 100644 --- a/frappe/core/doctype/file/test_file.py +++ b/frappe/core/doctype/file/test_file.py @@ -501,7 +501,7 @@ class TestFile(FrappeTestCase): test_file.file_url = frappe.utils.get_url("unknown.jpg") test_file.make_thumbnail(suffix="xs") self.assertEqual( - json.loads(frappe.message_log[0]).get("message"), + frappe.message_log[0].get("message"), f"File '{frappe.utils.get_url('unknown.jpg')}' not found", ) self.assertEqual(test_file.thumbnail_url, None) diff --git a/frappe/core/doctype/file/utils.py b/frappe/core/doctype/file/utils.py index b106ea67c5..9795c73d9e 100644 --- a/frappe/core/doctype/file/utils.py +++ b/frappe/core/doctype/file/utils.py @@ -13,7 +13,7 @@ from PIL import Image import frappe from frappe import _, safe_decode -from frappe.utils import cstr, encode, get_files_path, random_string, strip +from frappe.utils import cint, cstr, encode, get_files_path, random_string, strip from frappe.utils.file_manager import safe_b64decode from frappe.utils.image import optimize_image @@ -339,6 +339,7 @@ def attach_files_to_document(doc: "Document", event) -> None: "attached_to_name": doc.name, "attached_to_doctype": doc.doctype, "attached_to_field": df.fieldname, + "is_private": cint(value.startswith("/private")), }, ) continue @@ -357,6 +358,51 @@ def attach_files_to_document(doc: "Document", event) -> None: doc.log_error("Error Attaching File") +def relink_files(doc, fieldname, temp_doc_name): + if not temp_doc_name: + return + from frappe.utils.data import add_to_date, now_datetime + + """ + Relink files attached to incorrect document name to the new document name + by check if file with temp name exists that was created in last 60 minutes + """ + mislinked_file = frappe.db.exists( + "File", + { + "file_url": doc.get(fieldname), + "attached_to_name": temp_doc_name, + "attached_to_doctype": doc.doctype, + "attached_to_field": fieldname, + "creation": ( + "between", + [add_to_date(date=now_datetime(), minutes=-60), now_datetime()], + ), + }, + ) + """If file exists, attach it to the new docname""" + if mislinked_file: + frappe.db.set_value( + "File", + mislinked_file, + field={ + "attached_to_name": doc.name, + }, + ) + return + + +def relink_mismatched_files(doc: "Document") -> None: + if not doc.get("__temporary_name", None): + return + attach_fields = doc.meta.get("fields", {"fieldtype": ["in", ["Attach", "Attach Image"]]}) + for df in attach_fields: + if doc.get(df.fieldname): + relink_files(doc, df.fieldname, doc.__temporary_name) + # delete temporary name after relinking is done + doc.delete_key("__temporary_name") + + def decode_file_content(content: bytes) -> bytes: if isinstance(content, str): content = content.encode("utf-8") diff --git a/frappe/core/doctype/page/page.json b/frappe/core/doctype/page/page.json index e913f126af..b5e9941a6d 100644 --- a/frappe/core/doctype/page/page.json +++ b/frappe/core/doctype/page/page.json @@ -102,14 +102,16 @@ "icon": "fa fa-file", "idx": 1, "links": [], - "modified": "2022-08-03 12:20:54.219236", + "modified": "2023-10-22 22:41:25.568952", "modified_by": "Administrator", "module": "Core", "name": "Page", + "naming_rule": "By fieldname", "owner": "Administrator", "permissions": [ { "create": 1, + "delete": 1, "email": 1, "print": 1, "read": 1, diff --git a/frappe/core/doctype/page/page.py b/frappe/core/doctype/page/page.py index 77029b8c67..270ece6fa5 100644 --- a/frappe/core/doctype/page/page.py +++ b/frappe/core/doctype/page/page.py @@ -2,9 +2,10 @@ # License: MIT. See LICENSE import os +import shutil import frappe -from frappe import _, conf, safe_decode +from frappe import _, conf, get_module_path, safe_decode from frappe.build import html_to_js_template from frappe.core.doctype.custom_role.custom_role import get_custom_allowed_roles from frappe.desk.form.meta import get_code_files_via_hooks, get_js @@ -103,7 +104,18 @@ class Page(Document): return d def on_trash(self): + if not frappe.conf.developer_mode and not frappe.flags.in_migrate: + 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) + + def delete_folder_with_contents(self): + module_path = get_module_path(self.module) + dir_path = os.path.join(module_path, "page", frappe.scrub(self.name)) + + if os.path.exists(dir_path): + shutil.rmtree(dir_path, ignore_errors=True) def is_permitted(self): """Returns true if Has Role is not set or the user is allowed.""" @@ -173,11 +185,6 @@ class Page(Document): # flag for not caching this page self._dynamic_page = True - if frappe.lang != "en": - from frappe.translate import get_lang_js - - self.script += get_lang_js("page", self.name) - for path in get_code_files_via_hooks("page_js", self.name): js = get_js(path) if js: diff --git a/frappe/core/doctype/page/test_page.py b/frappe/core/doctype/page/test_page.py index edf7f7c9b8..61e7ed99a0 100644 --- a/frappe/core/doctype/page/test_page.py +++ b/frappe/core/doctype/page/test_page.py @@ -1,5 +1,9 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE +import os +import unittest +from unittest.mock import patch + import frappe from frappe.tests.utils import FrappeTestCase @@ -16,3 +20,18 @@ class TestPage(FrappeTestCase): frappe.NameError, frappe.get_doc(dict(doctype="Page", page_name="Settings", module="Core")).insert, ) + + @unittest.skipUnless( + os.access(frappe.get_app_path("frappe"), os.W_OK), "Only run if frappe app paths is writable" + ) + @patch.dict(frappe.conf, {"developer_mode": 1}) + def test_trashing(self): + page = frappe.new_doc("Page", page_name=frappe.generate_hash(), module="Core").insert() + + page.delete() + frappe.db.commit() + + module_path = frappe.get_module_path(page.module) + dir_path = os.path.join(module_path, "page", frappe.scrub(page.name)) + + self.assertFalse(os.path.exists(dir_path)) diff --git a/frappe/core/doctype/prepared_report/prepared_report.py b/frappe/core/doctype/prepared_report/prepared_report.py index 37023a238d..48e10abdbd 100644 --- a/frappe/core/doctype/prepared_report/prepared_report.py +++ b/frappe/core/doctype/prepared_report/prepared_report.py @@ -1,7 +1,6 @@ # Copyright (c) 2018, Frappe Technologies and contributors # License: MIT. See LICENSE - - +import gzip import json from contextlib import suppress from typing import Any @@ -13,7 +12,7 @@ from frappe.desk.form.load import get_attachments from frappe.desk.query_report import generate_report_result from frappe.model.document import Document from frappe.monitor import add_data_to_monitor -from frappe.utils import add_to_date, gzip_compress, gzip_decompress, now +from frappe.utils import add_to_date, now from frappe.utils.background_jobs import enqueue # If prepared report runs for longer than this time it's automatically considered as failed @@ -84,8 +83,8 @@ class PreparedReport(Document): attached_file = frappe.get_doc("File", attachment.name) if with_file_name: - return (gzip_decompress(attached_file.get_content()), attachment.file_name) - return gzip_decompress(attached_file.get_content()) + return (gzip.decompress(attached_file.get_content()), attachment.file_name) + return gzip.decompress(attached_file.get_content()) def generate_report(prepared_report): @@ -222,7 +221,7 @@ def create_json_gz_file(data, dt, dn): frappe.utils.data.format_datetime(frappe.utils.now(), "Y-m-d-H:M") ) encoded_content = frappe.safe_encode(frappe.as_json(data)) - compressed_content = gzip_compress(encoded_content) + compressed_content = gzip.compress(encoded_content) # Call save() file function to upload and attach the file _file = frappe.get_doc( diff --git a/frappe/core/doctype/recorder/recorder.js b/frappe/core/doctype/recorder/recorder.js index 185f655786..37d387b711 100644 --- a/frappe/core/doctype/recorder/recorder.js +++ b/frappe/core/doctype/recorder/recorder.js @@ -2,6 +2,9 @@ // For license information, please see license.txt frappe.ui.form.on("Recorder", { + onload: function (frm) { + frm.fields_dict.sql_queries.grid.only_sortable(); + }, refresh: function (frm) { frm.disable_save(); frm._sort_order = {}; diff --git a/frappe/core/doctype/rq_job/rq_job.py b/frappe/core/doctype/rq_job/rq_job.py index b6a6f99b57..453a375a5a 100644 --- a/frappe/core/doctype/rq_job/rq_job.py +++ b/frappe/core/doctype/rq_job/rq_job.py @@ -87,7 +87,9 @@ class RQJob(Document): matched_job_ids = RQJob.get_matching_job_ids(args)[start : start + page_length] conn = get_redis_conn() - jobs = [serialize_job(job) for job in Job.fetch_many(job_ids=matched_job_ids, connection=conn)] + jobs = [ + serialize_job(job) for job in Job.fetch_many(job_ids=matched_job_ids, connection=conn) if job + ] return sorted(jobs, key=lambda j: j.modified, reverse=order_desc) diff --git a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.json b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.json index cc2a0e870a..d6b1359337 100644 --- a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.json +++ b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.json @@ -53,6 +53,7 @@ { "allow_in_quick_entry": 1, "depends_on": "eval:doc.frequency==='Cron'", + "description": "
*  *  *  *  *\n\u252c  \u252c  \u252c  \u252c  \u252c\n\u2502  \u2502  \u2502  \u2502  \u2502\n\u2502  \u2502  \u2502  \u2502  \u2514 day of week (0 - 6) (0 is Sunday)\n\u2502  \u2502  \u2502  \u2514\u2500\u2500\u2500\u2500\u2500 month (1 - 12)\n\u2502  \u2502  \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 day of month (1 - 31)\n\u2502  \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 hour (0 - 23)\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 minute (0 - 59)\n\n---\n\n* - Any value\n/ - Step values\n
\n", "fieldname": "cron_format", "fieldtype": "Data", "label": "Cron Format", @@ -100,7 +101,7 @@ "link_fieldname": "scheduled_job_type" } ], - "modified": "2022-06-28 02:55:12.470915", + "modified": "2023-10-14 11:26:05.005930", "modified_by": "Administrator", "module": "Core", "name": "Scheduled Job Type", diff --git a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py index fc84204b37..6f56180c89 100644 --- a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py +++ b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py @@ -105,9 +105,12 @@ class ScheduledJobType(Document): if not self.cron_format: self.cron_format = CRON_MAP[self.frequency] - return croniter( - self.cron_format, get_datetime(self.last_execution or datetime(2000, 1, 1)) - ).get_next(datetime) + # If this is a cold start then last_execution will not be set. + # Creation is set as fallback because if very old fallback is set job might trigger + # immediately, even when it's meant to be daily. + # A dynamic fallback like current time might miss the scheduler interval and job will never start. + last_execution = get_datetime(self.last_execution or self.creation) + return croniter(self.cron_format, last_execution).get_next(datetime) def execute(self): self.scheduler_log = None diff --git a/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py b/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py index 7edad24ac4..6d77c876dc 100644 --- a/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py +++ b/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py @@ -1,9 +1,12 @@ # Copyright (c) 2019, Frappe Technologies and Contributors # License: MIT. See LICENSE +from datetime import timedelta + import frappe from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs from frappe.tests.utils import FrappeTestCase from frappe.utils import get_datetime +from frappe.utils.data import add_to_date, now_datetime class TestScheduledJobType(FrappeTestCase): @@ -68,6 +71,22 @@ class TestScheduledJobType(FrappeTestCase): # runs every 15 mins job = frappe.get_doc("Scheduled Job Type", dict(method="frappe.oauth.delete_oauth2_data")) job.db_set("last_execution", "2019-01-01 00:00:00") + self.assertEqual(job.next_execution, get_datetime("2019-01-01 00:15:00")) self.assertTrue(job.is_event_due(get_datetime("2019-01-01 00:15:01"))) self.assertFalse(job.is_event_due(get_datetime("2019-01-01 00:05:06"))) self.assertFalse(job.is_event_due(get_datetime("2019-01-01 00:14:59"))) + + def test_cold_start(self): + now = now_datetime() + just_before_12_am = now.replace(hour=11, minute=59, second=30) + just_after_12_am = now.replace(hour=0, minute=0, second=30) + timedelta(days=1) + + job = frappe.new_doc("Scheduled Job Type") + job.frequency = "Daily" + job.set_user_and_timestamp() + + with self.freeze_time(just_before_12_am): + self.assertFalse(job.is_event_due()) + + with self.freeze_time(just_after_12_am): + self.assertTrue(job.is_event_due()) diff --git a/frappe/core/doctype/server_script/server_script.json b/frappe/core/doctype/server_script/server_script.json index 67cb6e75ea..50f5bfcfe8 100644 --- a/frappe/core/doctype/server_script/server_script.json +++ b/frappe/core/doctype/server_script/server_script.json @@ -136,6 +136,7 @@ }, { "depends_on": "eval:doc.event_frequency==='Cron'", + "description": "
*  *  *  *  *\n\u252c  \u252c  \u252c  \u252c  \u252c\n\u2502  \u2502  \u2502  \u2502  \u2502\n\u2502  \u2502  \u2502  \u2502  \u2514 day of week (0 - 6) (0 is Sunday)\n\u2502  \u2502  \u2502  \u2514\u2500\u2500\u2500\u2500\u2500 month (1 - 12)\n\u2502  \u2502  \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 day of month (1 - 31)\n\u2502  \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 hour (0 - 23)\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 minute (0 - 59)\n\n---\n\n* - Any value\n/ - Step values\n
\n", "fieldname": "cron_format", "fieldtype": "Data", "label": "Cron Format" @@ -148,7 +149,7 @@ "link_fieldname": "server_script" } ], - "modified": "2023-05-27 16:33:16.595424", + "modified": "2023-10-14 11:24:46.478533", "modified_by": "Administrator", "module": "Core", "name": "Server Script", diff --git a/frappe/core/doctype/server_script/server_script.py b/frappe/core/doctype/server_script/server_script.py index 1a0017e443..a58e50dddc 100644 --- a/frappe/core/doctype/server_script/server_script.py +++ b/frappe/core/doctype/server_script/server_script.py @@ -75,6 +75,7 @@ class ServerScript(Document): return super().clear_cache() def on_trash(self): + frappe.cache.delete_value("server_script_map") if self.script_type == "Scheduler Event": for job in self.scheduled_jobs: frappe.delete_doc("Scheduled Job Type", job.name) diff --git a/frappe/core/doctype/sms_settings/sms_settings.py b/frappe/core/doctype/sms_settings/sms_settings.py index 0047de1daa..1a68368ba0 100644 --- a/frappe/core/doctype/sms_settings/sms_settings.py +++ b/frappe/core/doctype/sms_settings/sms_settings.py @@ -30,7 +30,7 @@ def validate_receiver_nos(receiver_list): validated_receiver_list = [] for d in receiver_list: if not d: - break + continue # remove invalid character for x in [" ", "-", "(", ")"]: diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json index 76126fd4fa..54b23094f8 100644 --- a/frappe/core/doctype/system_settings/system_settings.json +++ b/frappe/core/doctype/system_settings/system_settings.json @@ -217,7 +217,7 @@ "label": "Security" }, { - "default": "60:00", + "default": "170:00", "description": "Example: Setting this to 24:00 will log out a user if they are not active for 24:00 hours.", "fieldname": "session_expiry", "fieldtype": "Data", @@ -292,6 +292,7 @@ "label": "Brute Force Security" }, { + "default": "10", "fieldname": "allow_consecutive_login_attempts", "fieldtype": "Int", "label": "Allow Consecutive Login Attempts " @@ -602,7 +603,7 @@ "icon": "fa fa-cog", "issingle": 1, "links": [], - "modified": "2023-09-25 16:49:16.652874", + "modified": "2023-10-17 16:12:28.145496", "modified_by": "Administrator", "module": "Core", "name": "System Settings", diff --git a/frappe/core/doctype/user/test_user.py b/frappe/core/doctype/user/test_user.py index 1ca0a56ec0..dc973c9e8f 100644 --- a/frappe/core/doctype/user/test_user.py +++ b/frappe/core/doctype/user/test_user.py @@ -420,7 +420,7 @@ class TestUser(FrappeTestCase): self.assertEqual(update_password(new_password, key=test_user.reset_password_key), "/") update_password(old_password, old_password=new_password) self.assertEqual( - json.loads(frappe.message_log[0]).get("message"), + frappe.message_log[0].get("message"), "Password reset instructions have been sent to your email", ) diff --git a/frappe/core/doctype/user/user.js b/frappe/core/doctype/user/user.js index f168ad920f..a12dda661e 100644 --- a/frappe/core/doctype/user/user.js +++ b/frappe/core/doctype/user/user.js @@ -123,7 +123,8 @@ frappe.ui.form.on("User", { !doc.__unsaved && frappe.all_timezones && (hasChanged(doc.language, frappe.boot.user.language) || - hasChanged(doc.time_zone, frappe.boot.time_zone.user)) + hasChanged(doc.time_zone, frappe.boot.time_zone.user) || + hasChanged(doc.desk_theme, frappe.boot.user.desk_theme)) ) { frappe.msgprint(__("Refreshing...")); window.location.reload(); diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 1af7af72e5..cdb3e394ee 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -248,7 +248,7 @@ class User(Document): frappe.local.login_manager.logout(user=self.name) # toggle notifications based on the user's status - toggle_notifications(self.name, enable=cint(self.enabled)) + toggle_notifications(self.name, enable=cint(self.enabled), ignore_permissions=True) def add_system_manager_role(self): if self.is_system_manager_disabled(): @@ -549,6 +549,10 @@ class User(Document): # delete user permissions frappe.db.delete("User Permission", {"user": self.name}) + # Delete OAuth data + frappe.db.delete("OAuth Authorization Code", {"user": self.name}) + frappe.db.delete("Token Cache", {"user": self.name}) + def before_rename(self, old_name, new_name, merge=False): frappe.clear_cache(user=old_name) self.validate_rename(old_name, new_name) @@ -775,7 +779,7 @@ def get_timezones(): @frappe.whitelist() -def get_all_roles(arg=None): +def get_all_roles(): """return all roles""" active_domains = frappe.get_active_domains() @@ -789,7 +793,7 @@ def get_all_roles(arg=None): order_by="name", ) - return [role.get("name") for role in roles] + return sorted([role.get("name") for role in roles]) @frappe.whitelist() diff --git a/frappe/core/report/permitted_documents_for_user/permitted_documents_for_user.py b/frappe/core/report/permitted_documents_for_user/permitted_documents_for_user.py index 25657e17e8..b865c23b11 100644 --- a/frappe/core/report/permitted_documents_for_user/permitted_documents_for_user.py +++ b/frappe/core/report/permitted_documents_for_user/permitted_documents_for_user.py @@ -59,6 +59,6 @@ def query_doctypes(doctype, txt, searchfield, start, page_len, filters): return [ [dt] for dt in can_read - if txt.lower().replace("%", "") in dt.lower() + if txt.lower().replace("%", "") in frappe._(dt).lower() and (include_single_doctypes or dt not in single_doctypes) ] diff --git a/frappe/custom/doctype/custom_field/custom_field.json b/frappe/custom/doctype/custom_field/custom_field.json index b685d69192..53a003c88e 100644 --- a/frappe/custom/doctype/custom_field/custom_field.json +++ b/frappe/custom/doctype/custom_field/custom_field.json @@ -153,10 +153,10 @@ }, { "default": "0", - "description": "If checked, this field will be not overwritten based on Fetch From if a value already exists.", + "description": "If unchecked, the value will always be re-fetched on save.", "fieldname": "fetch_if_empty", "fieldtype": "Check", - "label": "Fetch If Empty" + "label": "Fetch on Save if Empty" }, { "fieldname": "options_help", @@ -450,7 +450,7 @@ "idx": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2023-06-08 19:05:51.737234", + "modified": "2023-10-25 06:55:10.713382", "modified_by": "Administrator", "module": "Custom", "name": "Custom Field", diff --git a/frappe/custom/doctype/customize_form/customize_form.json b/frappe/custom/doctype/customize_form/customize_form.json index 8d4d194338..d345698f2d 100644 --- a/frappe/custom/doctype/customize_form/customize_form.json +++ b/frappe/custom/doctype/customize_form/customize_form.json @@ -30,10 +30,10 @@ "links", "document_states_section", "states", - "form_tab", - "form_builder", "fields_section_break", "fields", + "form_tab", + "form_builder", "settings_tab", "form_settings_section", "image_field", @@ -393,7 +393,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2023-08-29 12:31:55.808848", + "modified": "2023-11-07 19:25:32.656641", "modified_by": "Administrator", "module": "Custom", "name": "Customize Form", diff --git a/frappe/custom/doctype/customize_form_field/customize_form_field.json b/frappe/custom/doctype/customize_form_field/customize_form_field.json index 4127598291..fa86df2735 100644 --- a/frappe/custom/doctype/customize_form_field/customize_form_field.json +++ b/frappe/custom/doctype/customize_form_field/customize_form_field.json @@ -193,10 +193,10 @@ }, { "default": "0", - "description": "If checked, this field will be not overwritten based on Fetch From if a value already exists.", + "description": "If unchecked, the value will always be re-fetched on save.", "fieldname": "fetch_if_empty", "fieldtype": "Check", - "label": "Fetch If Empty" + "label": "Fetch on Save if Empty" }, { "fieldname": "permissions", @@ -477,7 +477,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2023-06-08 19:05:37.767838", + "modified": "2023-10-25 06:55:50.718441", "modified_by": "Administrator", "module": "Custom", "name": "Customize Form Field", diff --git a/frappe/custom/doctype/doctype_layout/doctype_layout.py b/frappe/custom/doctype/doctype_layout/doctype_layout.py index 2589270944..c155f32ed1 100644 --- a/frappe/custom/doctype/doctype_layout/doctype_layout.py +++ b/frappe/custom/doctype/doctype_layout/doctype_layout.py @@ -32,7 +32,7 @@ class DocTypeLayout(Document): @frappe.whitelist() def sync_fields(self): - doctype_fields = frappe.get_meta(self.document_type).fields + doctype_fields = frappe.get_meta(self.document_type, cached=False).fields if self.is_new(): added_fields = [field.fieldname for field in doctype_fields] diff --git a/frappe/database/db_manager.py b/frappe/database/db_manager.py index bd696d00e3..6431217585 100644 --- a/frappe/database/db_manager.py +++ b/frappe/database/db_manager.py @@ -57,6 +57,7 @@ class DbManager: esc = make_esc("$ ") pv = which("pv") + mariadb_cli = which("mariadb") or which("mysql") if pv: pipe = f"{pv} {source} |" @@ -68,7 +69,7 @@ class DbManager: if pipe: print("Restoring Database file...") - command = "{pipe} mysql -u {user} -p{password} -h{host} -P{port} {target} {source}" + command = "{pipe} {mariadb_cli} -u {user} -p{password} -h{host} -P{port} {target} {source}" command = command.format( pipe=pipe, user=esc(user), @@ -77,6 +78,7 @@ class DbManager: target=esc(target), source=source, port=frappe.conf.db_port, + mariadb_cli=mariadb_cli, ) os.system(command) diff --git a/frappe/database/postgres/database.py b/frappe/database/postgres/database.py index edb2dc745a..f0a75e2e68 100644 --- a/frappe/database/postgres/database.py +++ b/frappe/database/postgres/database.py @@ -160,11 +160,16 @@ class PostgresDatabase(PostgresExceptionUtil, Database): return LazyDecode(self._cursor.query) def get_connection(self): - conn = psycopg2.connect( - "host='{}' dbname='{}' user='{}' password='{}' port={}".format( - self.host, self.user, self.user, self.password, self.port - ) - ) + conn_settings = { + "user": self.user, + "dbname": self.user, + "host": self.host, + "password": self.password, + } + if self.port: + conn_settings["port"] = self.port + + conn = psycopg2.connect(**conn_settings) conn.set_isolation_level(ISOLATION_LEVEL_REPEATABLE_READ) return conn diff --git a/frappe/desk/desktop.py b/frappe/desk/desktop.py index 4701879982..8ae20f7bb0 100644 --- a/frappe/desk/desktop.py +++ b/frappe/desk/desktop.py @@ -22,8 +22,7 @@ def handle_not_exist(fn): try: return fn(*args, **kwargs) except DoesNotExistError: - if frappe.message_log: - frappe.message_log.pop() + frappe.clear_last_message() return [] return wrapper diff --git a/frappe/desk/doctype/dashboard_chart_source/dashboard_chart_source.js b/frappe/desk/doctype/dashboard_chart_source/dashboard_chart_source.js index 6f1fa36ffd..1349adaf74 100644 --- a/frappe/desk/doctype/dashboard_chart_source/dashboard_chart_source.js +++ b/frappe/desk/doctype/dashboard_chart_source/dashboard_chart_source.js @@ -1,4 +1,19 @@ // Copyright (c) 2019, Frappe Technologies and contributors // For license information, please see license.txt -frappe.ui.form.on("Dashboard Chart Source", {}); +frappe.ui.form.on("Dashboard Chart Source", { + refresh: function (frm) { + if (!frm.is_new()) { + frm.add_custom_button( + __("Dashboard Chart"), + function () { + let dashboard_chart = frappe.model.get_new_doc("Dashboard Chart"); + dashboard_chart.chart_type = "Custom"; + dashboard_chart.source = frm.doc.name; + frappe.set_route("Form", "Dashboard Chart", dashboard_chart.name); + }, + __("Create") + ); + } + }, +}); diff --git a/frappe/desk/doctype/desktop_icon/desktop_icon.py b/frappe/desk/doctype/desktop_icon/desktop_icon.py index 524285f85d..7901ef9500 100644 --- a/frappe/desk/doctype/desktop_icon/desktop_icon.py +++ b/frappe/desk/doctype/desktop_icon/desktop_icon.py @@ -283,8 +283,7 @@ def set_desktop_icons(visible_list, ignore_duplicate=True): raise e else: visible_list.remove(module_name) - if frappe.message_log: - frappe.message_log.pop() + frappe.clear_last_message() # set the order set_order(visible_list) diff --git a/frappe/desk/doctype/note/note.py b/frappe/desk/doctype/note/note.py index ede8ecbc5d..ec89e7878c 100644 --- a/frappe/desk/doctype/note/note.py +++ b/frappe/desk/doctype/note/note.py @@ -54,3 +54,7 @@ def get_permission_query_conditions(user): user = frappe.session.user return f"(`tabNote`.owner = {frappe.db.escape(user)} or `tabNote`.public = 1)" + + +def has_permission(doc, user): + return doc.public or doc.owner == user diff --git a/frappe/desk/doctype/notification_settings/notification_settings.py b/frappe/desk/doctype/notification_settings/notification_settings.py index 45b946ec28..431c94f53d 100644 --- a/frappe/desk/doctype/notification_settings/notification_settings.py +++ b/frappe/desk/doctype/notification_settings/notification_settings.py @@ -70,7 +70,7 @@ def create_notification_settings(user): _doc.insert(ignore_permissions=True) -def toggle_notifications(user: str, enable: bool = False): +def toggle_notifications(user: str, enable: bool = False, ignore_permissions=False): try: settings = frappe.get_doc("Notification Settings", user) except frappe.DoesNotExistError: @@ -79,7 +79,7 @@ def toggle_notifications(user: str, enable: bool = False): if settings.enabled != enable: settings.enabled = enable - settings.save() + settings.save(ignore_permissions=ignore_permissions) @frappe.whitelist() diff --git a/frappe/desk/doctype/tag/tag.py b/frappe/desk/doctype/tag/tag.py index 05adaed926..8ee18fa74b 100644 --- a/frappe/desk/doctype/tag/tag.py +++ b/frappe/desk/doctype/tag/tag.py @@ -48,8 +48,6 @@ def add_tags(tags, dt, docs, color=None): for tag in tags: DocTags(dt).add(doc, tag) - # return tag - @frappe.whitelist() def remove_tag(tag, dt, dn): @@ -153,6 +151,7 @@ def update_tags(doc, tags): :param doc: Document to be added to global tags """ + doc.check_permission("write") new_tags = {tag.strip() for tag in tags.split(",") if tag} existing_tags = [ tag.tag diff --git a/frappe/desk/doctype/todo/todo.json b/frappe/desk/doctype/todo/todo.json index 518ca00374..c3b534d272 100644 --- a/frappe/desk/doctype/todo/todo.json +++ b/frappe/desk/doctype/todo/todo.json @@ -62,8 +62,11 @@ "label": "Color" }, { + "allow_in_quick_entry": 1, + "default": "Today", "fieldname": "date", "fieldtype": "Date", + "in_list_view": 1, "in_standard_filter": 1, "label": "Due Date", "oldfieldname": "date", @@ -158,7 +161,7 @@ "icon": "fa fa-check", "idx": 2, "links": [], - "modified": "2021-09-16 11:36:34.586898", + "modified": "2023-10-05 07:44:38.476400", "modified_by": "Administrator", "module": "Desk", "name": "ToDo", @@ -196,4 +199,4 @@ "title_field": "description", "track_changes": 1, "track_seen": 1 -} \ No newline at end of file +} diff --git a/frappe/desk/doctype/workspace/workspace.py b/frappe/desk/doctype/workspace/workspace.py index 195f9a8ba5..758681b0dc 100644 --- a/frappe/desk/doctype/workspace/workspace.py +++ b/frappe/desk/doctype/workspace/workspace.py @@ -242,6 +242,12 @@ def new_page(new_page): if page.get("public") and not is_workspace_manager(): return + elif ( + not page.get("public") + and page.get("for_user") != frappe.session.user + and not is_workspace_manager() + ): + frappe.throw(_("Cannot create private workspace of other users"), frappe.PermissionError) doc = frappe.new_doc("Workspace") doc.title = page.get("title") @@ -283,6 +289,16 @@ 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() + ): + frappe.throw( + _("Need Workspace Manager role to edit private workspace of other users"), + frappe.PermissionError, + ) + if doc: doc.title = title doc.icon = icon @@ -328,7 +344,11 @@ def hide_unhide_page(page_name: str, is_hidden: bool): _("Need Workspace Manager role to hide/unhide public workspaces"), frappe.PermissionError ) - if not page.get("public") and page.get("for_user") != frappe.session.user: + if ( + not page.get("public") + and page.get("for_user") != frappe.session.user + and not is_workspace_manager() + ): frappe.throw(_("Cannot update private workspace of other users"), frappe.PermissionError) page.is_hidden = int(is_hidden) @@ -387,7 +407,17 @@ def delete_page(page): page = loads(page) if page.get("public") and not is_workspace_manager(): - return + frappe.throw( + _("Cannot delete public workspace without Workspace Manager role"), + frappe.PermissionError, + ) + elif not page.get("public") and not is_workspace_manager(): + workspace_owner = frappe.get_value("Workspace", page.get("name"), "for_user") + if workspace_owner != frappe.session.user: + frappe.throw( + _("Cannot delete private workspace of other users"), + frappe.PermissionError, + ) if frappe.db.exists("Workspace", page.get("name")): frappe.get_doc("Workspace", page.get("name")).delete(ignore_permissions=True) diff --git a/frappe/desk/form/assign_to.py b/frappe/desk/form/assign_to.py index b1b14ee28b..dc8dbc7cf5 100644 --- a/frappe/desk/form/assign_to.py +++ b/frappe/desk/form/assign_to.py @@ -39,7 +39,7 @@ def get(args=None): @frappe.whitelist() -def add(args=None): +def add(args=None, *, ignore_permissions=False): """add in someone's to do list args = { "assign_to": [], @@ -63,6 +63,8 @@ def add(args=None): "status": "Open", "allocated_to": assign_to, } + if not ignore_permissions: + frappe.get_doc(args["doctype"], args["name"]).check_permission() if frappe.get_all("ToDo", filters=filters): users_with_duplicate_todo.append(assign_to) @@ -144,7 +146,7 @@ def add_multiple(args=None): add(args) -def close_all_assignments(doctype, name): +def close_all_assignments(doctype, name, ignore_permissions=False): assignments = frappe.get_all( "ToDo", fields=["allocated_to", "name"], @@ -154,26 +156,42 @@ def close_all_assignments(doctype, name): return False for assign_to in assignments: - set_status(doctype, name, todo=assign_to.name, assign_to=assign_to.allocated_to, status="Closed") + set_status( + doctype, + name, + todo=assign_to.name, + assign_to=assign_to.allocated_to, + status="Closed", + ignore_permissions=ignore_permissions, + ) return True @frappe.whitelist() -def remove(doctype, name, assign_to): - return set_status(doctype, name, "", assign_to, status="Cancelled") +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 close(doctype: str, name: str, assign_to: str): +def close(doctype: str, name: str, assign_to: str, ignore_permissions=False): 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") + return set_status( + doctype, name, "", assign_to, status="Closed", ignore_permissions=ignore_permissions + ) -def set_status(doctype, name, todo=None, assign_to=None, status="Cancelled"): +def set_status( + doctype, name, todo=None, assign_to=None, status="Cancelled", ignore_permissions=False +): """remove from todo""" + + if not ignore_permissions: + frappe.get_doc(doctype, name).check_permission() try: if not todo: todo = frappe.db.get_value( @@ -201,7 +219,7 @@ def set_status(doctype, name, todo=None, assign_to=None, status="Cancelled"): return get({"doctype": doctype, "name": name}) -def clear(doctype, name): +def clear(doctype, name, ignore_permissions=False): """ Clears assignments, return False if not assigned. """ @@ -215,7 +233,12 @@ def clear(doctype, name): for assign_to in assignments: set_status( - doctype, name, todo=assign_to.name, assign_to=assign_to.allocated_to, status="Cancelled" + doctype, + name, + todo=assign_to.name, + assign_to=assign_to.allocated_to, + status="Cancelled", + ignore_permissions=ignore_permissions, ) return True diff --git a/frappe/desk/form/linked_with.py b/frappe/desk/form/linked_with.py index 8f569b5a9e..cbe6dd7acc 100644 --- a/frappe/desk/form/linked_with.py +++ b/frappe/desk/form/linked_with.py @@ -38,6 +38,7 @@ def get_submitted_linked_docs(doctype: str, name: str) -> list[tuple]: 3. Searching for links is going to be a tree like structure where at every level, you will be finding documents using parent document and parent document links. """ + frappe.has_permission(doctype, doc=name) tree = SubmittableDocumentTree(doctype, name) visited_documents = tree.get_all_children() docs = [] @@ -70,7 +71,7 @@ class SubmittableDocumentTree: def get_all_children(self): """Get all nodes of a tree except the root node (all the nested submitted - documents those are present in referencing tables (dependent tables). + documents those are present in referencing tables dependent tables). """ while self.to_be_visited_documents: next_level_children = defaultdict(list) @@ -100,6 +101,10 @@ class SubmittableDocumentTree: child_docs = defaultdict(list) for field in referencing_fields: + if field["fieldname"] == "amended_from": + # perf: amended_from links are always linked to cancelled documents. + continue + links = ( get_referencing_documents( parent_dt, @@ -427,8 +432,7 @@ def get_linked_docs(doctype: str, name: str, linkinfo: dict | None = None) -> di link_meta_bundle = frappe.desk.form.load.get_meta_bundle(dt) except Exception as e: if isinstance(e, frappe.DoesNotExistError): - if frappe.local.message_log: - frappe.local.message_log.pop() + frappe.clear_last_message() continue linkmeta = link_meta_bundle[0] @@ -502,8 +506,7 @@ def get_linked_docs(doctype: str, name: str, linkinfo: dict | None = None) -> di ret = None except frappe.PermissionError: - if frappe.local.message_log: - frappe.local.message_log.pop() + frappe.clear_last_message() continue @@ -515,6 +518,7 @@ def get_linked_docs(doctype: str, name: str, linkinfo: dict | None = None) -> di @frappe.whitelist() def get(doctype, docname): + frappe.has_permission(doctype, doc=docname) linked_doctypes = get_linked_doctypes(doctype=doctype) return get_linked_docs(doctype=doctype, name=docname, linkinfo=linked_doctypes) diff --git a/frappe/desk/form/meta.py b/frappe/desk/form/meta.py index d0a6b2501c..40497030a9 100644 --- a/frappe/desk/form/meta.py +++ b/frappe/desk/form/meta.py @@ -1,6 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE -import io import os import frappe @@ -45,9 +44,6 @@ def get_meta(doctype, cached=True) -> "FormMeta": else: meta = FormMeta(doctype) - if frappe.local.lang != "en": - meta.set_translations(frappe.local.lang) - return meta @@ -256,18 +252,6 @@ class FormMeta(Meta): self.set("__form_grid_templates", templates) - def set_translations(self, lang): - from frappe.translate import extract_messages_from_code, make_dict_from_messages - - self.set("__messages", frappe.get_lang_dict("doctype", self.name)) - - # set translations for grid templates - if self.get("__form_grid_templates"): - for content in self.get("__form_grid_templates").values(): - messages = extract_messages_from_code(content) - messages = make_dict_from_messages(messages) - self.get("__messages").update(messages) - def load_dashboard(self): self.set("__dashboard", self.get_dashboard_data()) diff --git a/frappe/desk/form/save.py b/frappe/desk/form/save.py index 180717da40..795a7deeb9 100644 --- a/frappe/desk/form/save.py +++ b/frappe/desk/form/save.py @@ -17,6 +17,9 @@ def savedocs(doc, action): """save / submit / update doclist""" doc = frappe.get_doc(json.loads(doc)) capture_doc(doc, action) + if doc.get("__islocal") and doc.name.startswith("new-" + doc.doctype.lower().replace(" ", "-")): + # required to relink missing attachments if they exist. + doc.__temporary_name = doc.name set_local_name(doc) # action diff --git a/frappe/desk/page/setup_wizard/setup_wizard.js b/frappe/desk/page/setup_wizard/setup_wizard.js index c237624fff..8d42b804cd 100644 --- a/frappe/desk/page/setup_wizard/setup_wizard.js +++ b/frappe/desk/page/setup_wizard/setup_wizard.js @@ -404,7 +404,7 @@ frappe.setup.slides_settings = [ fieldname: "enable_telemetry", label: __("Allow sending usage data for improving applications"), fieldtype: "Check", - default: 1, + default: cint(frappe.telemetry.can_enable()), depends_on: "eval:frappe.telemetry.can_enable()", }, { diff --git a/frappe/desk/query_report.py b/frappe/desk/query_report.py index 1d5f1f5f01..7ca483d806 100644 --- a/frappe/desk/query_report.py +++ b/frappe/desk/query_report.py @@ -573,7 +573,9 @@ def get_filtered_data(ref_doctype, columns, data, user): if match_filters_per_doctype: for row in data: # Why linked_doctypes.get(ref_doctype)? because if column is empty, linked_doctypes[ref_doctype] is removed - if linked_doctypes.get(ref_doctype) and shared and row[linked_doctypes[ref_doctype]] in shared: + if ( + linked_doctypes.get(ref_doctype) and shared and row.get(linked_doctypes[ref_doctype]) in shared + ): result.append(row) elif has_match( diff --git a/frappe/desk/reportview.py b/frappe/desk/reportview.py index 102a708895..8f2f7f8dca 100644 --- a/frappe/desk/reportview.py +++ b/frappe/desk/reportview.py @@ -46,7 +46,7 @@ def get_list(): @frappe.whitelist() @frappe.read_only() -def get_count(): +def get_count() -> int: args = get_form_params() if is_virtual_doctype(args.doctype): @@ -65,7 +65,7 @@ def execute(doctype, *args, **kwargs): def get_form_params(): - """Stringify GET request parameters.""" + """parse GET request parameters.""" data = frappe._dict(frappe.local.form_dict) clean_params(data) validate_args(data) @@ -215,12 +215,12 @@ def clean_params(data): def parse_json(data): - if isinstance(data.get("filters"), str): - data["filters"] = json.loads(data["filters"]) - if isinstance(data.get("or_filters"), str): - data["or_filters"] = json.loads(data["or_filters"]) - if isinstance(data.get("fields"), str): - data["fields"] = ["*"] if data["fields"] == "*" else json.loads(data["fields"]) + if (filters := data.get("filters")) and isinstance(filters, str): + data["filters"] = json.loads(filters) + if (or_filters := data.get("or_filters")) and isinstance(or_filters, str): + data["or_filters"] = json.loads(or_filters) + if (fields := data.get("fields")) and isinstance(fields, str): + data["fields"] = ["*"] if fields == "*" else json.loads(fields) if isinstance(data.get("docstatus"), str): data["docstatus"] = json.loads(data["docstatus"]) if isinstance(data.get("save_user_settings"), str): diff --git a/frappe/desk/search.py b/frappe/desk/search.py index fe43b7889f..8b76cd35e1 100644 --- a/frappe/desk/search.py +++ b/frappe/desk/search.py @@ -1,20 +1,24 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE -import functools import json import re +from typing import TypedDict + +from typing_extensions import NotRequired # not required in 3.11+ import frappe # Backward compatbility from frappe import _, is_whitelisted, validate_and_sanitize_search_inputs from frappe.database.schema import SPECIAL_CHAR_PATTERN +from frappe.model.db_query import get_order_by from frappe.permissions import has_permission from frappe.utils import cint, cstr, unique +from frappe.utils.data import make_filter_tuple -def sanitize_searchfield(searchfield): +def sanitize_searchfield(searchfield: str): if not searchfield: return @@ -22,19 +26,25 @@ def sanitize_searchfield(searchfield): frappe.throw(_("Invalid Search Field {0}").format(searchfield), frappe.DataError) +class LinkSearchResults(TypedDict): + value: str + description: str + label: NotRequired[str] + + # this is called by the Link Field @frappe.whitelist() def search_link( - doctype, - txt, - query=None, - filters=None, - page_length=10, - searchfield=None, - reference_doctype=None, - ignore_user_permissions=False, -): - search_widget( + doctype: str, + txt: str, + query: str | None = None, + filters: str | dict | list | None = None, + page_length: int = 10, + searchfield: str | None = None, + reference_doctype: str | None = None, + ignore_user_permissions: bool = False, +) -> list[LinkSearchResults]: + results = search_widget( doctype, txt.strip(), query, @@ -44,25 +54,23 @@ def search_link( reference_doctype=reference_doctype, ignore_user_permissions=ignore_user_permissions, ) - - frappe.response["results"] = build_for_autosuggest(frappe.response["values"], doctype=doctype) - del frappe.response["values"] + return build_for_autosuggest(results, doctype=doctype) # this is called by the search box @frappe.whitelist() def search_widget( - doctype, - txt, - query=None, - searchfield=None, - start=0, - page_length=10, - filters=None, + doctype: str, + txt: str, + query: str | None = None, + searchfield: str = None, + start: int = 0, + page_length: int = 10, + filters: str | None | dict | list = None, filter_fields=None, - as_dict=False, - reference_doctype=None, - ignore_user_permissions=False, + as_dict: bool = False, + reference_doctype: str | None = None, + ignore_user_permissions: bool = False, ): start = cint(start) @@ -78,11 +86,13 @@ def search_widget( standard_queries = frappe.get_hooks().standard_queries or {} - if query and query.split(maxsplit=1)[0].lower() != "select": - # by method + if not query and doctype in standard_queries: + query = standard_queries[doctype][-1] + + if query: # Query = custom search query i.e. python function try: is_whitelisted(frappe.get_attr(query)) - frappe.response["values"] = frappe.call( + return frappe.call( query, doctype, txt, @@ -93,9 +103,9 @@ def search_widget( as_dict=as_dict, reference_doctype=reference_doctype, ) - except frappe.exceptions.PermissionError as e: + except (frappe.PermissionError, frappe.AppNotInstalledError, ImportError): if frappe.local.conf.developer_mode: - raise e + raise else: frappe.respond_as_web_page( title="Invalid Method", @@ -103,153 +113,123 @@ def search_widget( indicator_color="red", http_status_code=404, ) - return - except Exception as e: - raise e - elif not query and doctype in standard_queries: - # from standard queries - search_widget( - doctype=doctype, - txt=txt, - query=standard_queries[doctype][0], - searchfield=searchfield, - start=start, - page_length=page_length, - filters=filters, - filter_fields=filter_fields, - as_dict=as_dict, - reference_doctype=reference_doctype, - ignore_user_permissions=ignore_user_permissions, + return [] + + meta = frappe.get_meta(doctype) + + if isinstance(filters, dict): + filters_items = filters.items() + filters = [] + for key, value in filters_items: + filters.append(make_filter_tuple(doctype, key, value)) + + if filters is None: + filters = [] + or_filters = [] + + # build from doctype + if txt: + field_types = { + "Data", + "Text", + "Small Text", + "Long Text", + "Link", + "Select", + "Read Only", + "Text Editor", + } + search_fields = ["name"] + if meta.title_field: + search_fields.append(meta.title_field) + + if meta.search_fields: + search_fields.extend(meta.get_search_fields()) + + for f in search_fields: + fmeta = meta.get_field(f.strip()) + if not meta.translated_doctype and (f == "name" or (fmeta and fmeta.fieldtype in field_types)): + or_filters.append([doctype, f.strip(), "like", f"%{txt}%"]) + + if meta.get("fields", {"fieldname": "enabled", "fieldtype": "Check"}): + filters.append([doctype, "enabled", "=", 1]) + if meta.get("fields", {"fieldname": "disabled", "fieldtype": "Check"}): + filters.append([doctype, "disabled", "!=", 1]) + + # format a list of fields combining search fields and filter fields + fields = get_std_fields_list(meta, searchfield or "name") + if filter_fields: + fields = list(set(fields + json.loads(filter_fields))) + formatted_fields = [f"`tab{meta.name}`.`{f.strip()}`" for f in fields] + + # Insert title field query after name + if meta.show_title_field_in_link: + formatted_fields.insert(1, f"`tab{meta.name}`.{meta.title_field} as `label`") + + order_by_based_on_meta = get_order_by(doctype, meta) + # `idx` is number of times a document is referred, check link_count.py + order_by = f"`tab{doctype}`.idx desc, {order_by_based_on_meta}" + + if not meta.translated_doctype: + _txt = frappe.db.escape((txt or "").replace("%", "").replace("@", "")) + # locate returns 0 if string is not found, convert 0 to null and then sort null to end in order by + _relevance = f"(1 / nullif(locate({_txt}, `tab{doctype}`.`name`), 0))" + formatted_fields.append(f"""{_relevance} as `_relevance`""") + # Since we are sorting by alias postgres needs to know number of column we are sorting + if frappe.db.db_type == "mariadb": + order_by = f"ifnull(_relevance, -9999) desc, {order_by}" + elif frappe.db.db_type == "postgres": + # Since we are sorting by alias postgres needs to know number of column we are sorting + order_by = f"{len(formatted_fields)} desc nulls last, {order_by}" + + ignore_permissions = doctype == "DocType" or ( + cint(ignore_user_permissions) + and has_permission( + doctype, + ptype="select" if frappe.only_has_select_perm(doctype) else "read", + parent_doctype=reference_doctype, ) - else: - meta = frappe.get_meta(doctype) + ) - if query: - frappe.throw(_("This query style is discontinued")) - # custom query - # frappe.response["values"] = frappe.db.sql(scrub_custom_query(query, searchfield, txt)) + values = frappe.get_list( + doctype, + filters=filters, + fields=formatted_fields, + or_filters=or_filters, + limit_start=start, + limit_page_length=None if meta.translated_doctype else page_length, + order_by=order_by, + ignore_permissions=ignore_permissions, + reference_doctype=reference_doctype, + as_list=not as_dict, + strict=False, + ) + + if meta.translated_doctype: + # Filtering the values array so that query is included in very element + values = ( + result + for result in values + if any( + re.search(f"{re.escape(txt)}.*", _(cstr(value)) or "", re.IGNORECASE) + for value in (result.values() if as_dict else result) + ) + ) + + # Sorting the values array so that relevant results always come first + # This will first bring elements on top in which query is a prefix of element + # Then it will bring the rest of the elements and sort them in lexicographical order + values = sorted(values, key=lambda x: relevance_sorter(x, txt, as_dict)) + + # remove _relevance from results + if not meta.translated_doctype: + if as_dict: + for r in values: + r.pop("_relevance", None) else: - if isinstance(filters, dict): - filters_items = filters.items() - filters = [] - for f in filters_items: - if isinstance(f[1], (list, tuple)): - filters.append([doctype, f[0], f[1][0], f[1][1]]) - else: - filters.append([doctype, f[0], "=", f[1]]) + values = [r[:-1] for r in values] - if filters is None: - filters = [] - or_filters = [] - - # build from doctype - if txt: - field_types = [ - "Data", - "Text", - "Small Text", - "Long Text", - "Link", - "Select", - "Read Only", - "Text Editor", - ] - search_fields = ["name"] - if meta.title_field: - search_fields.append(meta.title_field) - - if meta.search_fields: - search_fields.extend(meta.get_search_fields()) - - for f in search_fields: - fmeta = meta.get_field(f.strip()) - if not meta.translated_doctype and ( - f == "name" or (fmeta and fmeta.fieldtype in field_types) - ): - or_filters.append([doctype, f.strip(), "like", f"%{txt}%"]) - - if meta.get("fields", {"fieldname": "enabled", "fieldtype": "Check"}): - filters.append([doctype, "enabled", "=", 1]) - if meta.get("fields", {"fieldname": "disabled", "fieldtype": "Check"}): - filters.append([doctype, "disabled", "!=", 1]) - - # format a list of fields combining search fields and filter fields - fields = get_std_fields_list(meta, searchfield or "name") - if filter_fields: - fields = list(set(fields + json.loads(filter_fields))) - formatted_fields = [f"`tab{meta.name}`.`{f.strip()}`" for f in fields] - - # Insert title field query after name - if meta.show_title_field_in_link: - formatted_fields.insert(1, f"`tab{meta.name}`.{meta.title_field} as `label`") - - # In order_by, `idx` gets second priority, because it stores link count - from frappe.model.db_query import get_order_by - - order_by_based_on_meta = get_order_by(doctype, meta) - # 2 is the index of _relevance column - order_by = f"{order_by_based_on_meta}, `tab{doctype}`.idx desc" - - if not meta.translated_doctype: - formatted_fields.append( - """locate({_txt}, `tab{doctype}`.`name`) as `_relevance`""".format( - _txt=frappe.db.escape((txt or "").replace("%", "").replace("@", "")), - doctype=doctype, - ) - ) - order_by = f"_relevance, {order_by}" - - ignore_permissions = ( - True - if doctype == "DocType" - else ( - cint(ignore_user_permissions) - and has_permission( - doctype, - ptype="select" if frappe.only_has_select_perm(doctype) else "read", - ) - ) - ) - - values = frappe.get_list( - doctype, - filters=filters, - fields=formatted_fields, - or_filters=or_filters, - limit_start=start, - limit_page_length=None if meta.translated_doctype else page_length, - order_by=order_by, - ignore_permissions=ignore_permissions, - reference_doctype=reference_doctype, - as_list=not as_dict, - strict=False, - ) - - if meta.translated_doctype: - # Filtering the values array so that query is included in very element - values = ( - result - for result in values - if any( - re.search(f"{re.escape(txt)}.*", _(cstr(value)) or "", re.IGNORECASE) - for value in (result.values() if as_dict else result) - ) - ) - - # Sorting the values array so that relevant results always come first - # This will first bring elements on top in which query is a prefix of element - # Then it will bring the rest of the elements and sort them in lexicographical order - values = sorted(values, key=lambda x: relevance_sorter(x, txt, as_dict)) - - # remove _relevance from results - if not meta.translated_doctype: - if as_dict: - for r in values: - r.pop("_relevance") - else: - values = [r[:-1] for r in values] - - frappe.response["values"] = values + return values def get_std_fields_list(meta, key): @@ -270,7 +250,7 @@ def get_std_fields_list(meta, key): return sflist -def build_for_autosuggest(res: list[tuple], doctype: str) -> list[dict]: +def build_for_autosuggest(res: list[tuple], doctype: str) -> list[LinkSearchResults]: def to_string(parts): return ", ".join( unique(_(cstr(part)) if meta.translated_doctype else cstr(part) for part in parts if part) diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py index 149e42dd80..9bcf328116 100755 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -309,7 +309,7 @@ class EmailAccount(Document): except OSError: if in_receive: # timeout while connecting, see receive.py connect method - description = frappe.message_log.pop() if frappe.message_log else "Socket Error" + description = frappe.clear_last_message() if frappe.message_log else "Socket Error" if test_internet(): self.db_set("no_failed", self.no_failed + 1) if self.no_failed > 2: @@ -348,14 +348,17 @@ class EmailAccount(Document): return frappe.get_doc(cls.DOCTYPE, name) @classmethod - def find_one_by_filters(cls, **kwargs): + def find_one_by_filters(cls, **kwargs) -> "EmailAccount": name = frappe.db.get_value(cls.DOCTYPE, kwargs) return cls.find(name) if name else None @classmethod def find_from_config(cls): config = cls.get_account_details_from_site_config() - return cls.from_record(config) if config else None + if config: + account = cls.from_record(config) + account._from_site_config = True + return account @classmethod def create_dummy(cls): @@ -475,9 +478,22 @@ class EmailAccount(Document): } def get_smtp_server(self): + """Get SMTPServer (wrapper around actual smtplib object) for this account. + + Implementation Detail: Since SMTPServer is same for each email connection, the same *instance* + is returned every time this function is called from same EmailAccount object. + This enables reusabilty of connection for better performance.""" + return self._smtp_server_instance + + @functools.cached_property + def _smtp_server_instance(self): config = self.sendmail_config() return SMTPServer(**config) + def remove_unpicklable_values(self, state): + super().remove_unpicklable_values(state) + state.pop("_smtp_server_instance", None) + def handle_incoming_connect_error(self, description): if test_internet(): if self.get_failed_attempts_count() > 2: @@ -496,7 +512,7 @@ class EmailAccount(Document): } ) except assign_to.DuplicateToDoError: - frappe.message_log.pop() + frappe.clear_last_message() pass else: self.set_failed_attempts_count(self.get_failed_attempts_count() + 1) diff --git a/frappe/email/doctype/email_queue/email_queue.py b/frappe/email/doctype/email_queue/email_queue.py index 2dccd65b4e..da10ae5d16 100644 --- a/frappe/email/doctype/email_queue/email_queue.py +++ b/frappe/email/doctype/email_queue/email_queue.py @@ -6,11 +6,12 @@ import quopri import traceback from contextlib import suppress from email.parser import Parser -from email.policy import SMTPUTF8 +from email.policy import SMTPUTF8, default import frappe from frappe import _, safe_encode, task from frappe.core.utils import html2text +from frappe.database.database import savepoint from frappe.email.doctype.email_account.email_account import EmailAccount from frappe.email.email_body import add_attachment, get_email, get_formatted_html from frappe.email.queue import get_unsubcribed_url, get_unsubscribe_message @@ -30,6 +31,7 @@ from frappe.utils import ( sbool, split_emails, ) +from frappe.utils.deprecations import deprecated from frappe.utils.verified_command import get_signed_params @@ -85,7 +87,7 @@ class EmailQueue(Document): return duplicate @classmethod - def new(cls, doc_data, ignore_permissions=False): + def new(cls, doc_data, ignore_permissions=False) -> "EmailQueue": data = doc_data.copy() if not data.get("recipients"): return @@ -98,7 +100,7 @@ class EmailQueue(Document): return doc @classmethod - def find(cls, name): + def find(cls, name) -> "EmailQueue": return frappe.get_doc(cls.DOCTYPE, name) @classmethod @@ -165,14 +167,14 @@ class EmailQueue(Document): if method := get_hook_method("override_email_send"): method(self, self.sender, recipient.recipient, message) else: - if not frappe.flags.in_test: + if not frappe.flags.in_test or frappe.flags.testing_email: ctx.smtp_server.session.sendmail( from_addr=self.sender, to_addrs=recipient.recipient, msg=message ) ctx.update_recipient_status_to_sent(recipient) - if frappe.flags.in_test: + if frappe.flags.in_test and not frappe.flags.testing_email: frappe.flags.sent_mail = message return @@ -212,6 +214,7 @@ class EmailQueue(Document): @task(queue="short") +@deprecated def send_mail(email_queue_name, smtp_server_instance: SMTPServer = None): """This is equivalent to EmailQueue.send. @@ -230,11 +233,7 @@ class SendMailContext: self.queue_doc: EmailQueue = queue_doc self.email_account_doc = queue_doc.get_email_account() - self.smtp_server = smtp_server_instance or self.email_account_doc.get_smtp_server() - - # if smtp_server_instance is passed, then retain smtp session - # Note: smtp session will have to be manually closed - self.retain_smtp_session = bool(smtp_server_instance) + self.smtp_server: SMTPServer = smtp_server_instance or self.email_account_doc.get_smtp_server() self.sent_to_atleast_one_recipient = any( rec.recipient for rec in self.queue_doc.recipients if rec.is_mail_sent() @@ -245,9 +244,6 @@ class SendMailContext: return self def __exit__(self, exc_type, exc_val, exc_tb): - if not self.retain_smtp_session: - self.smtp_server.quit() - if exc_type: update_fields = {"error": "".join(traceback.format_tb(exc_tb))} if self.queue_doc.retry < get_email_retry_limit(): @@ -259,11 +255,27 @@ class SendMailContext: ) else: update_fields.update({"status": "Error"}) + self.notify_failed_email() else: update_fields = {"status": "Sent"} self.queue_doc.update_status(**update_fields, commit=True) + @savepoint(catch=Exception) + def notify_failed_email(self): + # Parse the email body to extract the subject + subject = Parser(policy=default).parsestr(self.queue_doc.message)["Subject"] + + # Construct the notification + notification = frappe.new_doc("Notification Log") + notification.for_user = self.queue_doc.owner + notification.set("type", "Alert") + notification.from_user = self.queue_doc.owner + notification.document_type = self.queue_doc.doctype + notification.document_name = self.queue_doc.name + notification.subject = _("Failed to send email with subject:") + f" {subject}" + notification.insert() + def update_recipient_status_to_sent(self, recipient): self.sent_to_atleast_one_recipient = True recipient.update_db(status="Sent", commit=True) diff --git a/frappe/email/doctype/email_queue/test_email_queue.py b/frappe/email/doctype/email_queue/test_email_queue.py index 5a608b1b23..7d76039b47 100644 --- a/frappe/email/doctype/email_queue/test_email_queue.py +++ b/frappe/email/doctype/email_queue/test_email_queue.py @@ -1,7 +1,9 @@ # Copyright (c) 2015, Frappe Technologies and Contributors # License: MIT. See LICENSE +import textwrap import frappe +from frappe.email.doctype.email_queue.email_queue import SendMailContext, get_email_retry_limit from frappe.tests.utils import FrappeTestCase @@ -39,3 +41,57 @@ class TestEmailQueue(FrappeTestCase): self.assertTrue(frappe.db.exists("Email Queue", new_record.name)) self.assertTrue(frappe.db.exists("Email Queue Recipient", {"parent": new_record.name})) + + def test_failed_email_notification(self): + subject = frappe.generate_hash() + email_record = frappe.new_doc("Email Queue") + email_record.sender = "Test " + email_record.message = textwrap.dedent( + f"""\ + MIME-Version: 1.0 + Message-Id: {frappe.generate_hash()} + X-Original-From: Test + Subject: {subject} + From: Test + To: + Date: {frappe.utils.now_datetime().strftime('%a, %d %b %Y %H:%M:%S %z')} + Reply-To: test@example.com + X-Frappe-Site: {frappe.local.site} + """ + ) + email_record.status = "Error" + email_record.retry = get_email_retry_limit() + email_record.priority = 1 + email_record.reference_doctype = "User" + email_record.reference_name = "Administrator" + email_record.insert() + + # Simulate an exception so that we get a notification + try: + with SendMailContext(queue_doc=email_record): + raise Exception("Test Exception") + except Exception: + pass + + notification_log = frappe.db.get_value( + "Notification Log", + {"subject": f"Failed to send email with subject: {subject}"}, + ) + self.assertTrue(notification_log) + + def test_perf_reusing_smtp_server(self): + """Ensure that same smtpserver instance is being returned when retrieved multiple times.""" + + self.assertTrue(frappe.new_doc("Email Queue").get_email_account()._from_site_config) + + def get_server(q): + return q.get_email_account().get_smtp_server() + + self.assertIs( + get_server(frappe.new_doc("Email Queue")), get_server(frappe.new_doc("Email Queue")) + ) + + q1 = frappe.new_doc("Email Queue", email_account="_Test Email Account 1") + q2 = frappe.new_doc("Email Queue", email_account="_Test Email Account 1") + self.assertIsNot(get_server(frappe.new_doc("Email Queue")), get_server(q1)) + self.assertIs(get_server(q1), get_server(q2)) diff --git a/frappe/email/doctype/newsletter/newsletter_list.js b/frappe/email/doctype/newsletter/newsletter_list.js index 0921de02b4..71e9423b7e 100644 --- a/frappe/email/doctype/newsletter/newsletter_list.js +++ b/frappe/email/doctype/newsletter/newsletter_list.js @@ -2,11 +2,11 @@ frappe.listview_settings["Newsletter"] = { add_fields: ["subject", "email_sent", "schedule_sending"], get_indicator: function (doc) { if (doc.email_sent) { - return [__("Sent"), "green", "email_sent,=,Yes"]; + return [__("Sent"), "green", "email_sent,=,1"]; } else if (doc.schedule_sending) { - return [__("Scheduled"), "purple", "email_sent,=,No|schedule_sending,=,Yes"]; + return [__("Scheduled"), "purple", "email_sent,=,0|schedule_sending,=,1"]; } else { - return [__("Not Sent"), "gray", "email_sent,=,No"]; + return [__("Not Sent"), "gray", "email_sent,=,0"]; } }, }; diff --git a/frappe/email/doctype/newsletter/test_newsletter.py b/frappe/email/doctype/newsletter/test_newsletter.py index edbc8ff425..9677b94de3 100644 --- a/frappe/email/doctype/newsletter/test_newsletter.py +++ b/frappe/email/doctype/newsletter/test_newsletter.py @@ -151,7 +151,7 @@ class TestNewsletter(TestNewsletterMixin, FrappeTestCase): "Newsletter Email Group", filters={"parent": name}, fields=["email_group"] ) - flush(from_test=True) + flush() confirmed_unsubscribe(to_unsubscribe, group[0].email_group) name = self.send_newsletter() diff --git a/frappe/email/doctype/notification/notification.js b/frappe/email/doctype/notification/notification.js index eb3e2e8634..5be70b14b0 100644 --- a/frappe/email/doctype/notification/notification.js +++ b/frappe/email/doctype/notification/notification.js @@ -50,7 +50,7 @@ frappe.notification = { if (frm.doc.channel === "Email") { receiver_fields = $.map(fields, function (d) { // Add User and Email fields from child into select dropdown - if (d.fieldtype == "Table") { + if (frappe.model.table_fields.includes(d.fieldtype)) { let child_fields = frappe.get_doc("DocType", d.options).fields; return $.map(child_fields, function (df) { return df.options == "Email" || diff --git a/frappe/email/doctype/notification/notification.py b/frappe/email/doctype/notification/notification.py index e1e94b1879..0262cd4f2d 100644 --- a/frappe/email/doctype/notification/notification.py +++ b/frappe/email/doctype/notification/notification.py @@ -313,7 +313,7 @@ def get_context(context): def send_sms(self, doc, context): send_sms( receiver_list=self.get_receiver_list(doc, context), - msg=frappe.render_template(self.message, context), + msg=frappe.utils.strip_html_tags(frappe.render_template(self.message, context)), ) def get_list_of_recipients(self, doc, context): diff --git a/frappe/email/email_body.py b/frappe/email/email_body.py index c4194b2e0e..268de161b3 100755 --- a/frappe/email/email_body.py +++ b/frappe/email/email_body.py @@ -509,9 +509,10 @@ def replace_filename_with_cid(message): # found match img_path = groups[0] - filename = img_path.rsplit("/")[-1] + img_path_escaped = frappe.utils.html_utils.unescape_html(img_path) + filename = img_path_escaped.rsplit("/")[-1] - filecontent = get_filecontent_from_path(img_path) + filecontent = get_filecontent_from_path(img_path_escaped) if not filecontent: message = re.sub(f"""embed=['"]{re.escape(img_path)}['"]""", "", message) continue diff --git a/frappe/email/inbox.py b/frappe/email/inbox.py index b0673a3312..ef5187e7e1 100644 --- a/frappe/email/inbox.py +++ b/frappe/email/inbox.py @@ -1,6 +1,7 @@ import json import frappe +from frappe.client import set_value def get_email_accounts(user=None): @@ -95,32 +96,32 @@ def create_email_flag_queue(names, action): @frappe.whitelist() -def mark_as_closed_open(communication, status): +def mark_as_closed_open(communication: str, status: str): """Set status to open or close""" - frappe.db.set_value("Communication", communication, "status", status) + set_value("Communication", communication, "status", status) @frappe.whitelist() -def move_email(communication, email_account): +def move_email(communication: str, email_account: str): """Move email to another email account.""" - frappe.db.set_value("Communication", communication, "email_account", email_account) + set_value("Communication", communication, "email_account", email_account) @frappe.whitelist() -def mark_as_trash(communication): +def mark_as_trash(communication: str): """Set email status to trash.""" - frappe.db.set_value("Communication", communication, "email_status", "Trash") + set_value("Communication", communication, "email_status", "Trash") @frappe.whitelist() -def mark_as_spam(communication, sender): +def mark_as_spam(communication: str, sender: str): """Set email status to spam.""" email_rule = frappe.db.get_value("Email Rule", {"email_id": sender}) if not email_rule: frappe.get_doc({"doctype": "Email Rule", "email_id": sender, "is_spam": 1}).insert( ignore_permissions=True ) - frappe.db.set_value("Communication", communication, "email_status", "Spam") + set_value("Communication", communication, "email_status", "Spam") def link_communication_to_document( diff --git a/frappe/email/queue.py b/frappe/email/queue.py index b481fd21cd..6c78383b0c 100755 --- a/frappe/email/queue.py +++ b/frappe/email/queue.py @@ -7,6 +7,11 @@ from frappe.utils import cint, cstr, get_url, now_datetime from frappe.utils.data import getdate from frappe.utils.verified_command import get_signed_params, verify_request +# After this percent of failures in every batch, entire batch is aborted. +# This usually indicates a systemic failure so we shouldn't keep trying to send emails. +EMAIL_QUEUE_BATCH_FAILURE_THRESHOLD_PERCENT = 0.33 +EMAIL_QUEUE_BATCH_FAILURE_THRESHOLD_COUNT = 10 + def get_emails_sent_this_month(email_account=None): """Get count of emails sent from a specific email account. @@ -124,35 +129,45 @@ def return_unsubscribed_page(email, doctype, name): ) -def flush(from_test=False): - """flush email queue, every time: called from scheduler""" - from frappe.email.doctype.email_queue.email_queue import send_mail +def flush(): + """flush email queue, every time: called from scheduler. + + This should not be called outside of background jobs. + """ + from frappe.email.doctype.email_queue.email_queue import EmailQueue # To avoid running jobs inside unit tests if frappe.are_emails_muted(): msgprint(_("Emails are muted")) - from_test = True if cint(frappe.db.get_default("suspend_email_queue")) == 1: return - for row in get_queue(): + email_queue_batch = get_queue() + if not email_queue_batch: + return + + failed_email_queues = [] + for row in email_queue_batch: try: - frappe.enqueue( - method=send_mail, - email_queue_name=row.name, - now=from_test, - job_id=f"email_queue_sendmail_{row.name}", - queue="short", - deduplicate=True, - ) + email_queue: EmailQueue = frappe.get_doc("Email Queue", row.name) + email_queue.send() except Exception: frappe.get_doc("Email Queue", row.name).log_error() + failed_email_queues.append(row.name) + + if ( + len(failed_email_queues) / len(email_queue_batch) > EMAIL_QUEUE_BATCH_FAILURE_THRESHOLD_PERCENT + and len(failed_email_queues) > EMAIL_QUEUE_BATCH_FAILURE_THRESHOLD_COUNT + ): + frappe.throw(_("Email Queue flushing aborted due to too many failures.")) def get_queue(): + batch_size = cint(frappe.conf.email_queue_batch_size) or 500 + return frappe.db.sql( - """select + f"""select name, sender from `tabEmail Queue` @@ -160,8 +175,8 @@ def get_queue(): (status='Not Sent' or status='Partially Sent') and (send_after is null or send_after < %(now)s) order - by priority desc, creation asc - limit 500""", + by priority desc, retry asc, creation asc + limit {batch_size}""", {"now": now_datetime()}, as_dict=True, ) diff --git a/frappe/email/receive.py b/frappe/email/receive.py index 1c9bc6955b..0d1e7393c3 100644 --- a/frappe/email/receive.py +++ b/frappe/email/receive.py @@ -8,6 +8,7 @@ import imaplib import json import poplib import re +import ssl import time from contextlib import suppress from email.header import decode_header @@ -72,11 +73,14 @@ class EmailServer: try: if cint(self.settings.use_ssl): self.imap = imaplib.IMAP4_SSL( - self.settings.host, self.settings.incoming_port, timeout=frappe.conf.get("pop_timeout") + self.settings.host, + self.settings.incoming_port, + timeout=frappe.conf.pop_timeout, + ssl_context=ssl.create_default_context(), ) else: self.imap = imaplib.IMAP4( - self.settings.host, self.settings.incoming_port, timeout=frappe.conf.get("pop_timeout") + self.settings.host, self.settings.incoming_port, timeout=frappe.conf.pop_timeout ) if cint(self.settings.use_starttls): @@ -106,11 +110,14 @@ class EmailServer: try: if cint(self.settings.use_ssl): self.pop = poplib.POP3_SSL( - self.settings.host, self.settings.incoming_port, timeout=frappe.conf.get("pop_timeout") + self.settings.host, + self.settings.incoming_port, + timeout=frappe.conf.pop_timeout, + context=ssl.create_default_context(), ) else: self.pop = poplib.POP3( - self.settings.host, self.settings.incoming_port, timeout=frappe.conf.get("pop_timeout") + self.settings.host, self.settings.incoming_port, timeout=frappe.conf.pop_timeout ) if self.settings.use_oauth: @@ -481,10 +488,7 @@ class Email: def show_attached_email_headers_in_content(self, part): # get the multipart/alternative message - try: - from html import escape # python 3.x - except ImportError: - from cgi import escape # python 2.x + from html import escape message = list(part.walk())[1] headers = [] diff --git a/frappe/email/smtp.py b/frappe/email/smtp.py index 7b15440ccf..8fb65b7cd6 100644 --- a/frappe/email/smtp.py +++ b/frappe/email/smtp.py @@ -61,6 +61,10 @@ class SMTPServer: @property def session(self): + """Get SMTP session. + + We make best effort to revive connection if it's disconnected by checking the connection + health before returning it to user.""" if self.is_session_active(): return self._session @@ -86,14 +90,29 @@ class SMTPServer: frappe.msgprint(res[1], raise_exception=frappe.OutgoingEmailError) self._session = _session + self._enqueue_connection_closure() return self._session except smtplib.SMTPAuthenticationError: self.throw_invalid_credentials_exception() - except OSError: + except OSError as e: # Invalid mail server -- due to refusing connection - frappe.throw(_("Invalid Outgoing Mail Server or Port"), title=_("Incorrect Configuration")) + frappe.throw( + _("Invalid Outgoing Mail Server or Port: {0}").format(str(e)), + title=_("Incorrect Configuration"), + ) + + def _enqueue_connection_closure(self): + if frappe.request and hasattr(frappe.request, "after_response"): + frappe.request.after_response.add(self.quit) + elif frappe.job: + frappe.job.after_job.add(self.quit) + else: + # Console? + import atexit + + atexit.register(self.quit) def is_session_active(self): if self._session: diff --git a/frappe/geo/country_info.py b/frappe/geo/country_info.py index 3267149d4c..e07581c056 100644 --- a/frappe/geo/country_info.py +++ b/frappe/geo/country_info.py @@ -8,6 +8,7 @@ import os from functools import lru_cache import frappe +from frappe.utils.deprecations import deprecated from frappe.utils.momentjs import get_all_timezones @@ -38,29 +39,23 @@ def _get_country_timezone_info(): return {"country_info": get_all(), "all_timezones": get_all_timezones()} +@deprecated def get_translated_dict(): - from babel.dates import Locale, get_timezone, get_timezone_name + return get_translated_countries() + + +def get_translated_countries(): + from babel.dates import Locale translated_dict = {} locale = Locale.parse(frappe.local.lang, sep="-") - # timezones - for tz in get_all_timezones(): - timezone_name = get_timezone_name(get_timezone(tz), locale=locale, width="short") - if timezone_name: - translated_dict[tz] = timezone_name + " - " + tz - # country names && currencies for country, info in get_all().items(): country_name = locale.territories.get((info.get("code") or "").upper()) if country_name: translated_dict[country] = country_name - currency = info.get("currency") - currency_name = locale.currencies.get(currency) - if currency_name: - translated_dict[currency] = currency_name - return translated_dict diff --git a/frappe/handler.py b/frappe/handler.py index c6e7d79878..6db6a7600f 100644 --- a/frappe/handler.py +++ b/frappe/handler.py @@ -15,6 +15,7 @@ from frappe.core.doctype.server_script.server_script_utils import get_server_scr from frappe.monitor import add_data_to_monitor from frappe.utils import cint from frappe.utils.csvutils import build_csv_response +from frappe.utils.deprecations import deprecated, deprecation_warning from frappe.utils.image import optimize_image from frappe.utils.response import build_response @@ -56,13 +57,11 @@ def handle(): # add the response to `message` label frappe.response["message"] = data - return build_response("json") - def execute_cmd(cmd, from_async=False): """execute a request as python module""" for hook in reversed(frappe.get_hooks("override_whitelisted_methods", {}).get(cmd, [])): - # override using the first hook + # override using the last hook cmd = hook break @@ -188,6 +187,17 @@ def upload_file(): optimize = frappe.form_dict.optimize content = None + if frappe.form_dict.get("library_file_name", False): + doc = frappe.get_value( + "File", + frappe.form_dict.library_file_name, + ["is_private", "file_url", "file_name"], + as_dict=True, + ) + is_private = doc.is_private + file_url = doc.file_url + filename = doc.file_name + if not ignore_permissions: check_write_permission(doctype, docname) @@ -273,6 +283,9 @@ def get_attr(cmd): if "." in cmd: method = frappe.get_attr(cmd) else: + deprecation_warning( + f"Calling shorthand for {cmd} is deprecated, please specify full path in RPC call." + ) method = globals()[cmd] frappe.log("method:" + cmd) return method @@ -334,5 +347,4 @@ def run_doc_method(method, docs=None, dt=None, dn=None, arg=None, args=None): add_data_to_monitor(methodname=method) -# for backwards compatibility -runserverobj = run_doc_method +runserverobj = deprecated(run_doc_method) diff --git a/frappe/hooks.py b/frappe/hooks.py index 6cccad8468..e134a2a660 100644 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -61,6 +61,10 @@ website_route_rules = [ website_redirects = [ {"source": r"/desk(.*)", "target": r"/app\1"}, + { + "source": "/.well-known/openid-configuration", + "target": "/api/method/frappe.integrations.oauth2.openid_configuration", + }, ] base_template = "templates/base.html" @@ -118,6 +122,7 @@ permission_query_conditions = { has_permission = { "Event": "frappe.desk.doctype.event.event.has_permission", "ToDo": "frappe.desk.doctype.todo.todo.has_permission", + "Note": "frappe.desk.doctype.note.note.has_permission", "User": "frappe.core.doctype.user.user.has_permission", "Dashboard Chart": "frappe.desk.doctype.dashboard_chart.dashboard_chart.has_permission", "Number Card": "frappe.desk.doctype.number_card.number_card.has_permission", @@ -264,11 +269,6 @@ scheduler_events = { ], } -get_translated_dict = { - ("doctype", "System Settings"): "frappe.geo.country_info.get_translated_dict", - ("page", "setup-wizard"): "frappe.geo.country_info.get_translated_dict", -} - sounds = [ {"name": "email", "src": "/assets/frappe/sounds/email.mp3", "volume": 0.1}, {"name": "submit", "src": "/assets/frappe/sounds/submit.mp3", "volume": 0.1}, @@ -413,6 +413,7 @@ ignore_links_on_delete = [ "Integration Request", "Unhandled Email", "Webhook Request Log", + "Workspace", ] # Request Hooks diff --git a/frappe/installer.py b/frappe/installer.py index f2ce450187..93facf2b0e 100644 --- a/frappe/installer.py +++ b/frappe/installer.py @@ -726,8 +726,8 @@ def _guess_mariadb_version() -> tuple[int] | None: # in non-interactive mode. # Use db.sql("select version()") instead if connection is available. with suppress(Exception): - mysql = which("mysql") - version_output = subprocess.getoutput(f"{mysql} --version") + mariadb = which("mariadb") or which("mysql") + version_output = subprocess.getoutput(f"{mariadb} --version") version_regex = r"(?P\d+\.\d+\.\d+)-MariaDB" version = re.search(version_regex, version_output).group("version") diff --git a/frappe/integrations/doctype/connected_app/connected_app.py b/frappe/integrations/doctype/connected_app/connected_app.py index d571b2ba00..d6b173d040 100644 --- a/frappe/integrations/doctype/connected_app/connected_app.py +++ b/frappe/integrations/doctype/connected_app/connected_app.py @@ -48,7 +48,8 @@ class ConnectedApp(Document): def validate(self): base_url = frappe.utils.get_url() callback_path = ( - "/api/method/frappe.integrations.doctype.connected_app.connected_app.callback/" + self.name + "/api/method/frappe.integrations.doctype.connected_app.connected_app.callback" + + f"?app={self.name}" ) self.redirect_uri = urljoin(base_url, callback_path) @@ -148,7 +149,7 @@ class ConnectedApp(Document): @frappe.whitelist(methods=["GET"], allow_guest=True) -def callback(code=None, state=None): +def callback(code=None, state=None, app=None): """Handle client's code. Called during the oauthorization flow by the remote oAuth2 server to @@ -161,11 +162,7 @@ def callback(code=None, state=None): frappe.local.response["location"] = "/login?" + urlencode({"redirect-to": frappe.request.url}) return - path = frappe.request.path[1:].split("/") - if len(path) != 4 or not path[3]: - frappe.throw(_("Invalid Parameters.")) - - connected_app = frappe.get_doc("Connected App", path[3]) + connected_app = frappe.get_doc("Connected App", app) token_cache = frappe.get_doc("Token Cache", connected_app.name + "-" + frappe.session.user) if state != token_cache.state: diff --git a/frappe/integrations/doctype/connected_app/test_connected_app.py b/frappe/integrations/doctype/connected_app/test_connected_app.py index 88441db6b2..49ed6236ff 100644 --- a/frappe/integrations/doctype/connected_app/test_connected_app.py +++ b/frappe/integrations/doctype/connected_app/test_connected_app.py @@ -126,7 +126,7 @@ class TestConnectedApp(FrappeTestCase): def delete_if_exists(attribute): doc = getattr(self, attribute, None) if doc: - doc.delete() + doc.delete(force=True) delete_if_exists("token_cache") delete_if_exists("connected_app") diff --git a/frappe/integrations/doctype/google_calendar/google_calendar.py b/frappe/integrations/doctype/google_calendar/google_calendar.py index 7125e243a9..3f412efc90 100644 --- a/frappe/integrations/doctype/google_calendar/google_calendar.py +++ b/frappe/integrations/doctype/google_calendar/google_calendar.py @@ -214,7 +214,7 @@ def get_google_calendar_object(g_calendar): "token_uri": GoogleOAuth.OAUTH_URL, "client_id": google_settings.client_id, "client_secret": google_settings.get_password(fieldname="client_secret", raise_exception=False), - "scopes": ["https://www.googleapis.com/auth/calendar/v3"], + "scopes": [SCOPES], } credentials = google.oauth2.credentials.Credentials(**credentials_dict) @@ -406,9 +406,9 @@ def insert_event_in_google_calendar(doc, method=None): Insert Events in Google Calendar if sync_with_google_calendar is checked. """ if ( - not frappe.db.exists("Google Calendar", {"name": doc.google_calendar}) + not doc.sync_with_google_calendar or doc.pulled_from_google_calendar - or not doc.sync_with_google_calendar + or not frappe.db.exists("Google Calendar", {"name": doc.google_calendar}) ): return @@ -470,9 +470,9 @@ def update_event_in_google_calendar(doc, method=None): # Workaround to avoid triggering updation when Event is being inserted since # creation and modified are same when inserting doc if ( - not frappe.db.exists("Google Calendar", {"name": doc.google_calendar}) + not doc.sync_with_google_calendar or doc.modified == doc.creation - or not doc.sync_with_google_calendar + or not frappe.db.exists("Google Calendar", {"name": doc.google_calendar}) ): return diff --git a/frappe/integrations/doctype/google_contacts/google_contacts.py b/frappe/integrations/doctype/google_contacts/google_contacts.py index cee04a92a1..65bd2bf1d3 100644 --- a/frappe/integrations/doctype/google_contacts/google_contacts.py +++ b/frappe/integrations/doctype/google_contacts/google_contacts.py @@ -155,6 +155,10 @@ def sync_contacts_from_google_contacts(g_contact): frappe.publish_realtime( "import_google_contacts", dict(progress=idx + 1, total=len(results)), user=frappe.session.user ) + # Work-around to fix + # https://github.com/frappe/frappe/issues/22648 + if not connection.get("names"): + continue for name in connection.get("names"): if name.get("metadata").get("primary"): diff --git a/frappe/integrations/doctype/integration_request/integration_request.json b/frappe/integrations/doctype/integration_request/integration_request.json index 98db8ea748..8565b2a0fd 100644 --- a/frappe/integrations/doctype/integration_request/integration_request.json +++ b/frappe/integrations/doctype/integration_request/integration_request.json @@ -101,7 +101,7 @@ }, { "fieldname": "url", - "fieldtype": "Data", + "fieldtype": "Small Text", "label": "URL", "read_only": 1 }, @@ -129,7 +129,7 @@ ], "in_create": 1, "links": [], - "modified": "2022-04-07 11:32:27.557548", + "modified": "2023-10-09 09:36:23.856188", "modified_by": "Administrator", "module": "Integrations", "name": "Integration Request", diff --git a/frappe/integrations/doctype/integration_request/integration_request.py b/frappe/integrations/doctype/integration_request/integration_request.py index 2254b09c74..a81e702dfc 100644 --- a/frappe/integrations/doctype/integration_request/integration_request.py +++ b/frappe/integrations/doctype/integration_request/integration_request.py @@ -4,7 +4,7 @@ import json import frappe -from frappe.integrations.utils import json_handler +from frappe.integrations.utils import get_json, json_handler from frappe.model.document import Document @@ -28,7 +28,7 @@ class IntegrationRequest(Document): request_headers: DF.Code | None request_id: DF.Data | None status: DF.Literal["", "Queued", "Authorized", "Completed", "Cancelled", "Failed"] - url: DF.Data | None + url: DF.SmallText | None # end: auto-generated types def autoname(self): if self.flags._name: @@ -45,7 +45,7 @@ class IntegrationRequest(Document): data = json.loads(self.data) data.update(params) - self.data = json.dumps(data) + self.data = get_json(data) self.status = status self.save(ignore_permissions=True) frappe.db.commit() diff --git a/frappe/integrations/doctype/social_login_key/social_login_key.json b/frappe/integrations/doctype/social_login_key/social_login_key.json index bb97d8f625..12514c3fdd 100644 --- a/frappe/integrations/doctype/social_login_key/social_login_key.json +++ b/frappe/integrations/doctype/social_login_key/social_login_key.json @@ -18,6 +18,8 @@ "icon", "column_break_1", "base_url", + "configuration_section", + "sign_ups", "client_urls", "authorize_url", "access_token_url", @@ -157,11 +159,24 @@ "fieldname": "user_id_property", "fieldtype": "Data", "label": "User ID Property" + }, + { + "collapsible": 1, + "fieldname": "configuration_section", + "fieldtype": "Section Break", + "label": "Configuration" + }, + { + "description": "Controls whether new users can sign up using this Social Login Key. If unset, Website Settings is respected. ", + "fieldname": "sign_ups", + "fieldtype": "Select", + "label": "Sign ups", + "options": "\nAllow\nDeny" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2020-09-30 14:37:13.616002", + "modified": "2023-10-14 12:22:23.601130", "modified_by": "Administrator", "module": "Integrations", "name": "Social Login Key", @@ -182,6 +197,7 @@ ], "sort_field": "modified", "sort_order": "DESC", + "states": [], "title_field": "provider_name", "track_changes": 1 } \ No newline at end of file diff --git a/frappe/integrations/doctype/social_login_key/social_login_key.py b/frappe/integrations/doctype/social_login_key/social_login_key.py index 3994413d57..4c1fc8708f 100644 --- a/frappe/integrations/doctype/social_login_key/social_login_key.py +++ b/frappe/integrations/doctype/social_login_key/social_login_key.py @@ -54,6 +54,7 @@ class SocialLoginKey(Document): icon: DF.Data | None provider_name: DF.Data redirect_url: DF.Data | None + sign_ups: DF.Literal["", "Allow", "Deny"] social_login_provider: DF.Literal[ "Custom", "Facebook", "Frappe", "GitHub", "Google", "Office 365", "Salesforce", "fairlogin" ] @@ -214,3 +215,13 @@ class SocialLoginKey(Document): return return providers.get(provider) if provider else providers + + +def provider_allows_signup(provider: str) -> bool: + from frappe.website.utils import is_signup_disabled + + sign_up_config = frappe.db.get_value("Social Login Key", provider, "sign_ups") + + if not (sign_up_config and provider): # fallback to global settings + return is_signup_disabled() + return sign_up_config == "Allow" diff --git a/frappe/integrations/doctype/social_login_key/test_social_login_key.py b/frappe/integrations/doctype/social_login_key/test_social_login_key.py index 90003da3f3..961cd41b65 100644 --- a/frappe/integrations/doctype/social_login_key/test_social_login_key.py +++ b/frappe/integrations/doctype/social_login_key/test_social_login_key.py @@ -7,13 +7,22 @@ from rauth import OAuth2Service import frappe from frappe.auth import CookieManager, LoginManager from frappe.integrations.doctype.social_login_key.social_login_key import BaseUrlNotSetError -from frappe.tests.utils import FrappeTestCase +from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import set_request from frappe.utils.oauth import login_via_oauth2 +TEST_GITHUB_USER = "githublogin@example.com" + class TestSocialLoginKey(FrappeTestCase): + def setUp(self) -> None: + frappe.set_user("Administrator") + frappe.delete_doc("User", TEST_GITHUB_USER, force=True) + super().setUp() + frappe.set_user("Guest") + def test_adding_frappe_social_login_provider(self): + frappe.set_user("Administrator") provider_name = "Frappe" social_login_key = make_social_login_key(social_login_provider=provider_name) social_login_key.get_social_login_provider(provider_name, initialize=True) @@ -40,17 +49,43 @@ class TestSocialLoginKey(FrappeTestCase): def test_normal_signup_and_github_login(self): github_social_login_setup() - if not frappe.db.exists("User", "githublogin@example.com"): - user = frappe.get_doc( - {"doctype": "User", "email": "githublogin@example.com", "first_name": "GitHub Login"} - ) - user.save(ignore_permissions=True) + if not frappe.db.exists("User", TEST_GITHUB_USER): + user = frappe.new_doc("User", email=TEST_GITHUB_USER, first_name="GitHub Login") + user.insert(ignore_permissions=True) mock_session = MagicMock() mock_session.get.side_effect = github_response_for_login with patch.object(OAuth2Service, "get_auth_session", return_value=mock_session): login_via_oauth2("github", "iwriu", {"token": "ewrwerwer"}) + self.assertEqual(frappe.session.user, TEST_GITHUB_USER) + + def test_force_disabled_signups(self): + key = github_social_login_setup() + key.sign_ups = "Deny" + key.save(ignore_permissions=True) + + mock_session = MagicMock() + mock_session.get.side_effect = github_response_for_login + + with patch.object(OAuth2Service, "get_auth_session", return_value=mock_session): + login_via_oauth2("github", "iwriu", {"token": "ewrwerwer"}) + self.assertEqual(frappe.session.user, "Guest") + + @change_settings("Website Settings", disable_signup=1) + def test_force_enabled_signups(self): + """Social login key can override website settings for disabled signups.""" + key = github_social_login_setup() + key.sign_ups = "Allow" + key.save(ignore_permissions=True) + + mock_session = MagicMock() + mock_session.get.side_effect = github_response_for_login + + with patch.object(OAuth2Service, "get_auth_session", return_value=mock_session): + login_via_oauth2("github", "iwriu", {"token": "ewrwerwer"}) + + self.assertEqual(frappe.session.user, TEST_GITHUB_USER) def make_social_login_key(**kwargs): @@ -83,7 +118,6 @@ def create_github_social_login_key(): social_login_key = make_social_login_key(social_login_provider=provider_name) social_login_key.get_social_login_provider(provider_name, initialize=True) - # Dummy client_id and client_secret social_login_key.client_id = "h6htd6q" social_login_key.client_secret = "keoererk988ekkhf8w9e8ewrjhhkjer9889" social_login_key.insert(ignore_permissions=True) @@ -125,7 +159,7 @@ def github_response_for_login(url, *args, **kwargs): "first_name": "Github Login", } else: - return_value = [{"email": "githublogin@example.com", "primary": True, "verified": True}] + return_value = [{"email": TEST_GITHUB_USER, "primary": True, "verified": True}] return MagicMock(status_code=200, json=MagicMock(return_value=return_value)) @@ -135,4 +169,4 @@ def github_social_login_setup(): frappe.local.cookie_manager = CookieManager() frappe.local.login_manager = LoginManager() - create_github_social_login_key() + return create_github_social_login_key() diff --git a/frappe/integrations/utils.py b/frappe/integrations/utils.py index 0f94d2ee29..9e80a9aa34 100644 --- a/frappe/integrations/utils.py +++ b/frappe/integrations/utils.py @@ -10,7 +10,7 @@ from frappe import _ from frappe.utils import get_request_session -def make_request(method, url, auth=None, headers=None, data=None, json=None): +def make_request(method, url, auth=None, headers=None, data=None, json=None, params=None): auth = auth or "" data = data or {} headers = headers or {} @@ -18,7 +18,7 @@ def make_request(method, url, auth=None, headers=None, data=None, json=None): try: s = get_request_session() frappe.flags.integration_request = s.request( - method, url, data=data, auth=auth, headers=headers, json=json + method, url, data=data, auth=auth, headers=headers, json=json, params=params ) frappe.flags.integration_request.raise_for_status() diff --git a/frappe/migrate.py b/frappe/migrate.py index 6b83521a7f..33c930e9da 100644 --- a/frappe/migrate.py +++ b/frappe/migrate.py @@ -1,6 +1,8 @@ # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE +import contextlib +import functools import json import os from textwrap import dedent @@ -36,14 +38,18 @@ BENCH_START_MESSAGE = dedent( def atomic(method): + @functools.wraps(method) def wrapper(*args, **kwargs): try: ret = method(*args, **kwargs) frappe.db.commit() return ret - except Exception: - frappe.db.rollback() - raise + except Exception as e: + # database itself can be gone while attempting rollback. + # We should preserve original exception in this case. + with contextlib.suppress(Exception): + frappe.db.rollback() + raise e return wrapper diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index a31114e112..78c20a2eae 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -782,8 +782,11 @@ class BaseDocument: else: values_to_fetch = ["name"] + [_df.fetch_from.split(".")[-1] for _df in fields_to_fetch] + # fallback to dict with field_to_fetch=None if link field value is not found + # (for compatibility, `values` must have same data type) + empty_values = _dict({value: None for value in values_to_fetch}) # don't cache if fetching other values too - values = frappe.db.get_value(doctype, docname, values_to_fetch, as_dict=True) + values = frappe.db.get_value(doctype, docname, values_to_fetch, as_dict=True) or empty_values if getattr(frappe.get_meta(doctype), "issingle", 0): values.name = doctype diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index f54886a4ef..62c0538298 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -3,10 +3,10 @@ """build query for doclistview and return results""" import copy +import datetime import json import re from collections import Counter -from datetime import datetime import frappe import frappe.defaults @@ -21,7 +21,6 @@ from frappe.model.utils import is_virtual_doctype from frappe.model.utils.user_settings import get_user_settings, update_user_settings from frappe.query_builder.utils import Column from frappe.utils import ( - add_to_date, cint, cstr, flt, @@ -30,7 +29,7 @@ from frappe.utils import ( get_timespan_date_range, make_filter_tuple, ) -from frappe.utils.data import sbool +from frappe.utils.data import DateTimeLikeObject, get_datetime, getdate, sbool LOCATE_PATTERN = re.compile(r"locate\([^,]+,\s*[`\"]?name[`\"]?\s*\)", flags=re.IGNORECASE) LOCATE_CAST_PATTERN = re.compile( @@ -853,7 +852,7 @@ class DatabaseQuery: value = frappe.db.format_date(f.value) fallback = "'0001-01-01'" - elif (df and df.fieldtype == "Datetime") or isinstance(f.value, datetime): + elif (df and df.fieldtype == "Datetime") or isinstance(f.value, datetime.datetime): value = frappe.db.format_datetime(f.value) fallback = f"'{FallBackDateTimeStr}'" @@ -1247,10 +1246,18 @@ def has_any_user_permission_for_doctype(doctype, user, applicable_for): def get_between_date_filter(value, df=None): + """Handle datetime filter bounds for between filter values. + + If date is passed but fieldtype is datetime then + from part is converted to start of day and to part is converted to end of day. + If any of filter part (to or from) are missing then: + start or end of current day is assumed as fallback. + If fieldtypes match with filter values then: + no change is applied. """ - return the formattted date as per the given example - [u'2017-11-01', u'2017-11-03'] => '2017-11-01 00:00:00.000000' AND '2017-11-04 00:00:00.000000' - """ + + fieldtype = df and df.fieldtype or "Datetime" + from_date = frappe.utils.nowdate() to_date = frappe.utils.nowdate() @@ -1260,18 +1267,35 @@ def get_between_date_filter(value, df=None): if len(value) >= 2: to_date = value[1] - if not df or (df and df.fieldtype == "Datetime"): - to_date = add_to_date(to_date, days=1) + # if filter value is date but fieldtype is datetime: + if fieldtype == "Datetime": + from_date = _convert_type_for_between_filters(from_date, set_time=datetime.time()) + to_date = _convert_type_for_between_filters(to_date, set_time=datetime.time(23, 59, 59, 999999)) - if df and df.fieldtype == "Datetime": - data = "'{}' AND '{}'".format( - frappe.db.format_datetime(from_date), - frappe.db.format_datetime(to_date), - ) + # If filter value is already datetime, do nothing. + if fieldtype == "Datetime": + cond = f"'{frappe.db.format_datetime(from_date)}' AND '{frappe.db.format_datetime(to_date)}'" else: - data = f"'{frappe.db.format_date(from_date)}' AND '{frappe.db.format_date(to_date)}'" + cond = f"'{frappe.db.format_date(from_date)}' AND '{frappe.db.format_date(to_date)}'" - return data + return cond + + +def _convert_type_for_between_filters( + value: DateTimeLikeObject, set_time: datetime.time +) -> datetime.datetime: + if isinstance(value, str): + if " " in value.strip(): + value = get_datetime(value) + else: + value = getdate(value) + + if isinstance(value, datetime.datetime): + return value + elif isinstance(value, datetime.date): + return datetime.datetime.combine(value, set_time) + + return value def get_additional_filter_field(additional_filters_config, f, value): diff --git a/frappe/model/delete_doc.py b/frappe/model/delete_doc.py index 150be95476..e2202882b1 100644 --- a/frappe/model/delete_doc.py +++ b/frappe/model/delete_doc.py @@ -245,42 +245,55 @@ def check_if_doc_is_linked(doc, method="Delete"): from frappe.model.rename_doc import get_link_fields link_fields = get_link_fields(doc.doctype) - ignore_linked_doctypes = doc.get("ignore_linked_doctypes") or [] + ignored_doctypes = set() + + if method == "Cancel" and (doc_ignore_flags := doc.get("ignore_linked_doctypes")): + ignored_doctypes.update(doc_ignore_flags) + if method == "Delete": + ignored_doctypes.update(frappe.get_hooks("ignore_links_on_delete")) for lf in link_fields: link_dt, link_field, issingle = lf["parent"], lf["fieldname"], lf["issingle"] + if link_dt in ignored_doctypes or link_field == "amended_from": + continue - if not issingle: - fields = ["name", "docstatus"] - if frappe.get_meta(link_dt).istable: - fields.extend(["parent", "parenttype"]) + try: + meta = frappe.get_meta(link_dt) + except frappe.DoesNotExistError: + frappe.clear_last_message() + # This mostly happens when app do not remove their customizations, we shouldn't + # prevent link checks from failing in those cases + continue - for item in frappe.db.get_values(link_dt, {link_field: doc.name}, fields, as_dict=True): - # available only in child table cases - item_parent = getattr(item, "parent", None) - linked_doctype = item.parenttype if item_parent else link_dt - - if linked_doctype in frappe.get_hooks("ignore_links_on_delete") or ( - linked_doctype in ignore_linked_doctypes and method == "Cancel" - ): - # don't check for communication and todo! - continue - - if method != "Delete" and (method != "Cancel" or not DocStatus(item.docstatus).is_submitted()): - # don't raise exception if not - # linked to a non-cancelled doc when deleting or to a submitted doc when cancelling - continue - elif link_dt == doc.doctype and (item_parent or item.name) == doc.name: - # don't raise exception if not - # linked to same item or doc having same name as the item - continue - else: - reference_docname = item_parent or item.name - raise_link_exists_exception(doc, linked_doctype, reference_docname) - - else: - if frappe.db.get_value(link_dt, None, link_field) == doc.name: + if issingle: + if frappe.db.get_single_value(link_dt, link_field) == doc.name: raise_link_exists_exception(doc, link_dt, link_dt) + continue + + fields = ["name", "docstatus"] + + if meta.istable: + fields.extend(["parent", "parenttype"]) + + for item in frappe.db.get_values(link_dt, {link_field: doc.name}, fields, as_dict=True): + # available only in child table cases + item_parent = getattr(item, "parent", None) + linked_parent_doctype = item.parenttype if item_parent else link_dt + + if linked_parent_doctype in ignored_doctypes: + continue + + if method != "Delete" and (method != "Cancel" or not DocStatus(item.docstatus).is_submitted()): + # don't raise exception if not + # linked to a non-cancelled doc when deleting or to a submitted doc when cancelling + continue + elif link_dt == doc.doctype and (item_parent or item.name) == doc.name: + # don't raise exception if not + # linked to same item or doc having same name as the item + continue + else: + reference_docname = item_parent or item.name + raise_link_exists_exception(doc, linked_parent_doctype, reference_docname) def check_if_doc_is_dynamically_linked(doc, method="Delete"): diff --git a/frappe/model/document.py b/frappe/model/document.py index d419abc727..4d5eec64fc 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -10,6 +10,7 @@ from werkzeug.exceptions import NotFound import frappe from frappe import _, is_whitelisted, msgprint +from frappe.core.doctype.file.utils import relink_mismatched_files from frappe.core.doctype.server_script.server_script_utils import run_server_script_for_doc_event from frappe.desk.form.document_follow import follow_document from frappe.integrations.doctype.webhook import run_webhooks @@ -22,6 +23,7 @@ from frappe.model.workflow import set_workflow_state_on_action, validate_workflo from frappe.types import DF from frappe.utils import compare, cstr, date_diff, file_lock, flt, get_datetime_str, now from frappe.utils.data import get_absolute_url +from frappe.utils.deprecations import deprecated from frappe.utils.global_search import update_global_search if TYPE_CHECKING: @@ -139,12 +141,6 @@ class Document(BaseDocument): def is_locked(self): return file_lock.lock_exists(self.get_signature()) - @staticmethod - def whitelist(fn): - """Decorator: Whitelist method to be called remotely via REST API.""" - frappe.whitelist()(fn) - return fn - def load_from_db(self): """Load document and children from database and create properties from fields""" @@ -252,9 +248,15 @@ class Document(BaseDocument): This will check for user permissions and execute `before_insert`, `validate`, `on_update`, `after_insert` methods if they are written. - :param ignore_permissions: Do not check permissions if True.""" + :param ignore_permissions: Do not check permissions if True. + :param ignore_links: Do not check validity of links if True. + :param ignore_if_duplicate: Do not raise error if a duplicate entry exists. + :param ignore_mandatory: Do not check missing mandatory fields if True. + :param set_name: Name to set for the document, if valid. + :param set_child_names: Whether to set names for the child documents. + """ if self.flags.in_print: - return + return self self.flags.notifications_executed = [] @@ -304,6 +306,7 @@ class Document(BaseDocument): if self.get("amended_from"): self.copy_attachments_from_amended_from() + relink_mismatched_files(self) self.run_post_save_methods() self.flags.in_insert = False @@ -553,6 +556,7 @@ class Document(BaseDocument): self._validate_selects() self._validate_non_negative() self._validate_length() + self._fix_rating_value() self._validate_code_fields() self._sync_autoname_field() self._extract_images_from_text_editor() @@ -600,6 +604,15 @@ class Document(BaseDocument): msg = get_msg(df) frappe.throw(msg, frappe.NonNegativeError, title=_("Negative Value")) + def _fix_rating_value(self): + for field in self.meta.get("fields", {"fieldtype": "Rating"}): + value = self.get(field.fieldname) + if not isinstance(value, float): + value = flt(value) + + # Make sure rating is between 0 and 1 + self.set(field.fieldname, max(0, min(value, 1))) + def validate_workflow(self): """Validate if the workflow transition is valid""" if frappe.flags.in_install == "frappe": @@ -1000,19 +1013,16 @@ class Document(BaseDocument): elif alert.event == "Method" and method == alert.method: _evaluate_alert(alert) - @whitelist.__func__ def _submit(self): """Submit the document. Sets `docstatus` = 1, then saves.""" self.docstatus = DocStatus.submitted() return self.save() - @whitelist.__func__ def _cancel(self): """Cancel the document. Sets `docstatus` = 2, then saves.""" self.docstatus = DocStatus.cancelled() return self.save() - @whitelist.__func__ def _rename( self, name: str, merge: bool = False, force: bool = False, validate_rename: bool = True ): @@ -1022,20 +1032,18 @@ class Document(BaseDocument): self.name = rename_doc(doc=self, new=name, merge=merge, force=force, validate=validate_rename) self.reload() - @whitelist.__func__ + @frappe.whitelist() def submit(self): """Submit the document. Sets `docstatus` = 1, then saves.""" return self._submit() - @whitelist.__func__ + @frappe.whitelist() def cancel(self): """Cancel the document. Sets `docstatus` = 2, then saves.""" return self._cancel() - @whitelist.__func__ - def rename( - self, name: str, merge: bool = False, force: bool = False, validate_rename: bool = True - ): + @frappe.whitelist() + def rename(self, name: str, merge=False, force=False, validate_rename=True): """Rename the document to `name`. This transforms the current object.""" return self._rename(name=name, merge=merge, force=force, validate_rename=validate_rename) @@ -1576,6 +1584,12 @@ class Document(BaseDocument): DocTags(self.doctype).add(self.name, tag) + def remove_tag(self, tag): + """Remove a Tag to this document""" + from frappe.desk.doctype.tag.tag import DocTags + + DocTags(self.doctype).remove(self.name, tag) + def get_tags(self): """Return a list of Tags attached to this document""" from frappe.desk.doctype.tag.tag import DocTags @@ -1622,8 +1636,8 @@ def execute_action(__doctype, __name, __action, **kwargs): frappe.db.rollback() # add a comment (?) - if frappe.local.message_log: - msg = json.loads(frappe.local.message_log[-1]).get("message") + if frappe.message_log: + msg = frappe.message_log[-1].get("message") else: msg = "
" + frappe.get_traceback() + "
" diff --git a/frappe/parallel_test_runner.py b/frappe/parallel_test_runner.py index 250e8bec76..ebfa5d8c32 100644 --- a/frappe/parallel_test_runner.py +++ b/frappe/parallel_test_runner.py @@ -10,7 +10,7 @@ import requests import frappe -from .test_runner import SLOW_TEST_THRESHOLD, make_test_records, set_test_email_config +from .test_runner import SLOW_TEST_THRESHOLD, make_test_records click_ctx = click.get_current_context(True) if click_ctx: @@ -38,7 +38,6 @@ class ParallelTestRunner: frappe.flags.in_test = True frappe.clear_cache() frappe.utils.scheduler.disable_scheduler() - set_test_email_config() self.before_test_setup() def before_test_setup(self): diff --git a/frappe/patches.txt b/frappe/patches.txt index 83db10d905..d3f6e30aee 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -230,3 +230,4 @@ execute:frappe.delete_doc_if_exists("Workspace", "Customization") execute:frappe.db.set_single_value("Document Naming Settings", "default_amend_naming", "Amend Counter") frappe.patches.v15_0.move_event_cancelled_to_status frappe.patches.v15_0.set_file_type +frappe.core.doctype.data_import.patches.remove_stale_docfields_from_legacy_version diff --git a/frappe/patches/v14_0/drop_data_import_legacy.py b/frappe/patches/v14_0/drop_data_import_legacy.py index 16d366a845..452d45182c 100644 --- a/frappe/patches/v14_0/drop_data_import_legacy.py +++ b/frappe/patches/v14_0/drop_data_import_legacy.py @@ -8,7 +8,7 @@ def execute(): table = frappe.utils.get_table_name(doctype) # delete the doctype record to avoid broken links - frappe.db.delete("DocType", {"name": doctype}) + frappe.delete_doc("DocType", doctype, force=True) # leaving table in database for manual cleanup click.secho( diff --git a/frappe/printing/doctype/print_format/print_format.py b/frappe/printing/doctype/print_format/print_format.py index e1ad28ef7b..eda084968a 100644 --- a/frappe/printing/doctype/print_format/print_format.py +++ b/frappe/printing/doctype/print_format/print_format.py @@ -66,7 +66,7 @@ class PrintFormat(Document): if ( self.standard == "Yes" and not frappe.local.conf.get("developer_mode") - and not (frappe.flags.in_import or frappe.flags.in_test) + and not (frappe.flags.in_migrate or frappe.flags.in_test) ): frappe.throw(frappe._("Standard Print Format cannot be updated")) diff --git a/frappe/public/css/fonts/inter/inter.css b/frappe/public/css/fonts/inter/inter.css index 88057a55b6..3be525aba0 100644 --- a/frappe/public/css/fonts/inter/inter.css +++ b/frappe/public/css/fonts/inter/inter.css @@ -1,3 +1,5 @@ +/* This file is depricated use Inter.scss instead. */ +/* Backward compatibility */ @font-face { font-family: 'Inter V'; font-weight: 100 900; diff --git a/frappe/public/css/fonts/inter/inter.scss b/frappe/public/css/fonts/inter/inter.scss new file mode 100644 index 0000000000..705fe2badd --- /dev/null +++ b/frappe/public/css/fonts/inter/inter.scss @@ -0,0 +1,167 @@ +// TODO instead of making copy of inter.css find a way to import it. +// workaround for css import as it fails for custom website_theme_template +@font-face { + font-family: "Inter V"; + font-weight: 100 900; + font-display: swap; + font-style: normal; + src: url("/assets/frappe/css/fonts/inter/Inter.var.woff2?v=3.19") format("woff2-variations"), + url("/assets/frappe/css/fonts/inter/Inter.var.woff2?v=3.19") format("woff2"); + src: url("/assets/frappe/css/fonts/inter/Inter.var.woff2?v=3.19") format("woff2") + tech("variations"); +} +@font-face { + font-family: "Inter V"; + font-weight: 100 900; + font-display: swap; + font-style: italic; + src: url("/assets/frappe/css/fonts/inter/Inter-Italic.var.woff2?v=3.19") + format("woff2-variations"), + url("/assets/frappe/css/fonts/inter/Inter-Italic.var.woff2?v=3.19") format("woff2"); + src: url("/assets/frappe/css/fonts/inter/Inter-Italic.var.woff2?v=3.19") format("woff2") + tech("variations"); +} +@font-face { + font-family: "Inter"; + font-display: swap; + font-style: italic; + font-weight: 100; + src: url("/assets/frappe/css/fonts/inter/inter_thinitalic.woff2") format("woff2"), + url("/assets/frappe/css/fonts/inter/inter_thinitalic.woff") format("woff"); +} + +@font-face { + font-family: "Inter"; + font-display: swap; + font-style: normal; + font-weight: 200; + src: url("/assets/frappe/css/fonts/inter/inter_extralight.woff2") format("woff2"), + url("/assets/frappe/css/fonts/inter/inter_extralight.woff") format("woff"); +} +@font-face { + font-family: "Inter"; + font-display: swap; + font-style: italic; + font-weight: 200; + src: url("/assets/frappe/css/fonts/inter/inter_extralightitalic.woff2") format("woff2"), + url("/assets/frappe/css/fonts/inter/inter_extralightitalic.woff") format("woff"); +} + +@font-face { + font-family: "Inter"; + font-display: swap; + font-style: normal; + font-weight: 300; + src: url("/assets/frappe/css/fonts/inter/inter_light.woff2") format("woff2"), + url("/assets/frappe/css/fonts/inter/inter_light.woff") format("woff"); +} +@font-face { + font-family: "Inter"; + font-display: swap; + font-style: italic; + font-weight: 300; + src: url("/assets/frappe/css/fonts/inter/inter_lightitalic.woff2") format("woff2"), + url("/assets/frappe/css/fonts/inter/inter_lightitalic.woff") format("woff"); +} + +@font-face { + font-family: "Inter"; + font-display: swap; + font-style: normal; + font-weight: 400; + src: url("/assets/frappe/css/fonts/inter/inter_regular.woff2") format("woff2"), + url("/assets/frappe/css/fonts/inter/inter_regular.woff") format("woff"); +} +@font-face { + font-family: "Inter"; + font-display: swap; + font-style: italic; + font-weight: 400; + src: url("/assets/frappe/css/fonts/inter/inter_italic.woff2") format("woff2"), + url("/assets/frappe/css/fonts/inter/inter_italic.woff") format("woff"); +} + +@font-face { + font-family: "Inter"; + font-display: swap; + font-style: normal; + font-weight: 500; + src: url("/assets/frappe/css/fonts/inter/inter_medium.woff2") format("woff2"), + url("/assets/frappe/css/fonts/inter/inter_medium.woff") format("woff"); +} +@font-face { + font-family: "Inter"; + font-display: swap; + font-style: italic; + font-weight: 500; + src: url("/assets/frappe/css/fonts/inter/inter_mediumitalic.woff2") format("woff2"), + url("/assets/frappe/css/fonts/inter/inter_mediumitalic.woff") format("woff"); +} + +@font-face { + font-family: "Inter"; + font-display: swap; + font-style: normal; + font-weight: 600; + src: url("/assets/frappe/css/fonts/inter/inter_semibold.woff2") format("woff2"), + url("/assets/frappe/css/fonts/inter/inter_semibold.woff") format("woff"); +} +@font-face { + font-family: "Inter"; + font-display: swap; + font-style: italic; + font-weight: 600; + src: url("/assets/frappe/css/fonts/inter/inter_semibolditalic.woff2") format("woff2"), + url("/assets/frappe/css/fonts/inter/inter_semibolditalic.woff") format("woff"); +} + +@font-face { + font-family: "Inter"; + font-display: swap; + font-style: normal; + font-weight: 700; + src: url("/assets/frappe/css/fonts/inter/inter_bold.woff2") format("woff2"), + url("/assets/frappe/css/fonts/inter/inter_bold.woff") format("woff"); +} +@font-face { + font-family: "Inter"; + font-display: swap; + font-style: italic; + font-weight: 700; + src: url("/assets/frappe/css/fonts/inter/inter_bolditalic.woff2") format("woff2"), + url("/assets/frappe/css/fonts/inter/inter_bolditalic.woff") format("woff"); +} + +@font-face { + font-family: "Inter"; + font-display: swap; + font-style: normal; + font-weight: 800; + src: url("/assets/frappe/css/fonts/inter/inter_extrabold.woff2") format("woff2"), + url("/assets/frappe/css/fonts/inter/inter_extrabold.woff") format("woff"); +} +@font-face { + font-family: "Inter"; + font-display: swap; + font-style: italic; + font-weight: 800; + src: url("/assets/frappe/css/fonts/inter/inter_extrabolditalic.woff2") format("woff2"), + url("/assets/frappe/css/fonts/inter/inter_extrabolditalic.woff") format("woff"); +} + +@font-face { + font-family: "Inter"; + font-display: swap; + font-style: normal; + font-weight: 900; + src: url("/assets/frappe/css/fonts/inter/inter_black.woff2") format("woff2"), + url("/assets/frappe/css/fonts/inter/inter_black.woff") format("woff"); +} +@font-face { + font-family: "Inter"; + font-display: swap; + font-style: italic; + font-weight: 900; + src: url("/assets/frappe/css/fonts/inter/inter_blackitalic.woff2") format("woff2"), + url("/assets/frappe/css/fonts/inter/inter_blackitalic.woff") format("woff"); +} diff --git a/frappe/public/css/octicons/octicons.scss b/frappe/public/css/octicons/octicons.scss index 0902cedce8..81e7d52d78 100755 --- a/frappe/public/css/octicons/octicons.scss +++ b/frappe/public/css/octicons/octicons.scss @@ -1,220 +1,548 @@ $octicons-font-path: "." !default; -$octicons-version: "396334ee3da78f4302d25c758ae3e3ce5dc3c97d"; +$octicons-version: "396334ee3da78f4302d25c758ae3e3ce5dc3c97d"; @font-face { - font-family: 'octicons'; - src: url('#{$octicons-font-path}/octicons.eot?#iefix&v=#{$octicons-version}') format('embedded-opentype'), - url('#{$octicons-font-path}/octicons.woff?v=#{$octicons-version}') format('woff'), - url('#{$octicons-font-path}/octicons.ttf?v=#{$octicons-version}') format('truetype'), - url('#{$octicons-font-path}/octicons.svg?v=#{$octicons-version}#octicons') format('svg'); - font-weight: normal; - font-style: normal; + font-family: "octicons"; + src: url("#{$octicons-font-path}/octicons.eot?#iefix&v=#{$octicons-version}") + format("embedded-opentype"), + url("#{$octicons-font-path}/octicons.woff?v=#{$octicons-version}") format("woff"), + url("#{$octicons-font-path}/octicons.ttf?v=#{$octicons-version}") format("truetype"), + url("#{$octicons-font-path}/octicons.svg?v=#{$octicons-version}#octicons") format("svg"); + font-weight: normal; + font-style: normal; } // .octicon is optimized for 16px. // .mega-octicon is optimized for 32px but can be used larger. -.octicon, .mega-octicon { - font: normal normal normal 16px/1 octicons; - display: inline-block; - text-decoration: none; - text-rendering: auto; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; +.octicon, +.mega-octicon { + font: normal normal normal 16px/1 octicons; + display: inline-block; + text-decoration: none; + text-rendering: auto; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} +.mega-octicon { + font-size: 32px; } -.mega-octicon { font-size: 32px; } -.octicon-alert:before { content: '\f02d'} /*  */ -.octicon-arrow-down:before { content: '\f03f'} /*  */ -.octicon-arrow-left:before { content: '\f040'} /*  */ -.octicon-arrow-right:before { content: '\f03e'} /*  */ -.octicon-arrow-small-down:before { content: '\f0a0'} /*  */ -.octicon-arrow-small-left:before { content: '\f0a1'} /*  */ -.octicon-arrow-small-right:before { content: '\f071'} /*  */ -.octicon-arrow-small-up:before { content: '\f09f'} /*  */ -.octicon-arrow-up:before { content: '\f03d'} /*  */ +.octicon-alert:before { + content: "\f02d"; +} /*  */ +.octicon-arrow-down:before { + content: "\f03f"; +} /*  */ +.octicon-arrow-left:before { + content: "\f040"; +} /*  */ +.octicon-arrow-right:before { + content: "\f03e"; +} /*  */ +.octicon-arrow-small-down:before { + content: "\f0a0"; +} /*  */ +.octicon-arrow-small-left:before { + content: "\f0a1"; +} /*  */ +.octicon-arrow-small-right:before { + content: "\f071"; +} /*  */ +.octicon-arrow-small-up:before { + content: "\f09f"; +} /*  */ +.octicon-arrow-up:before { + content: "\f03d"; +} /*  */ .octicon-microscope:before, -.octicon-beaker:before { content: '\f0dd'} /*  */ -.octicon-bell:before { content: '\f0de'} /*  */ -.octicon-book:before { content: '\f007'} /*  */ -.octicon-bookmark:before { content: '\f07b'} /*  */ -.octicon-briefcase:before { content: '\f0d3'} /*  */ -.octicon-broadcast:before { content: '\f048'} /*  */ -.octicon-browser:before { content: '\f0c5'} /*  */ -.octicon-bug:before { content: '\f091'} /*  */ -.octicon-calendar:before { content: '\f068'} /*  */ -.octicon-check:before { content: '\f03a'} /*  */ -.octicon-checklist:before { content: '\f076'} /*  */ -.octicon-chevron-down:before { content: '\f0a3'} /*  */ -.octicon-chevron-left:before { content: '\f0a4'} /*  */ -.octicon-chevron-right:before { content: '\f078'} /*  */ -.octicon-chevron-up:before { content: '\f0a2'} /*  */ -.octicon-circle-slash:before { content: '\f084'} /*  */ -.octicon-circuit-board:before { content: '\f0d6'} /*  */ -.octicon-clippy:before { content: '\f035'} /*  */ -.octicon-clock:before { content: '\f046'} /*  */ -.octicon-cloud-download:before { content: '\f00b'} /*  */ -.octicon-cloud-upload:before { content: '\f00c'} /*  */ -.octicon-code:before { content: '\f05f'} /*  */ -.octicon-color-mode:before { content: '\f065'} /*  */ +.octicon-beaker:before { + content: "\f0dd"; +} /*  */ +.octicon-bell:before { + content: "\f0de"; +} /*  */ +.octicon-book:before { + content: "\f007"; +} /*  */ +.octicon-bookmark:before { + content: "\f07b"; +} /*  */ +.octicon-briefcase:before { + content: "\f0d3"; +} /*  */ +.octicon-broadcast:before { + content: "\f048"; +} /*  */ +.octicon-browser:before { + content: "\f0c5"; +} /*  */ +.octicon-bug:before { + content: "\f091"; +} /*  */ +.octicon-calendar:before { + content: "\f068"; +} /*  */ +.octicon-check:before { + content: "\f03a"; +} /*  */ +.octicon-checklist:before { + content: "\f076"; +} /*  */ +.octicon-chevron-down:before { + content: "\f0a3"; +} /*  */ +.octicon-chevron-left:before { + content: "\f0a4"; +} /*  */ +.octicon-chevron-right:before { + content: "\f078"; +} /*  */ +.octicon-chevron-up:before { + content: "\f0a2"; +} /*  */ +.octicon-circle-slash:before { + content: "\f084"; +} /*  */ +.octicon-circuit-board:before { + content: "\f0d6"; +} /*  */ +.octicon-clippy:before { + content: "\f035"; +} /*  */ +.octicon-clock:before { + content: "\f046"; +} /*  */ +.octicon-cloud-download:before { + content: "\f00b"; +} /*  */ +.octicon-cloud-upload:before { + content: "\f00c"; +} /*  */ +.octicon-code:before { + content: "\f05f"; +} /*  */ +.octicon-color-mode:before { + content: "\f065"; +} /*  */ .octicon-comment-add:before, -.octicon-comment:before { content: '\f02b'} /*  */ -.octicon-comment-discussion:before { content: '\f04f'} /*  */ -.octicon-credit-card:before { content: '\f045'} /*  */ -.octicon-dash:before { content: '\f0ca'} /*  */ -.octicon-dashboard:before { content: '\f07d'} /*  */ -.octicon-database:before { content: '\f096'} /*  */ +.octicon-comment:before { + content: "\f02b"; +} /*  */ +.octicon-comment-discussion:before { + content: "\f04f"; +} /*  */ +.octicon-credit-card:before { + content: "\f045"; +} /*  */ +.octicon-dash:before { + content: "\f0ca"; +} /*  */ +.octicon-dashboard:before { + content: "\f07d"; +} /*  */ +.octicon-database:before { + content: "\f096"; +} /*  */ .octicon-clone:before, -.octicon-desktop-download:before { content: '\f0dc'} /*  */ -.octicon-device-camera:before { content: '\f056'} /*  */ -.octicon-device-camera-video:before { content: '\f057'} /*  */ -.octicon-device-desktop:before { content: '\f27c'} /*  */ -.octicon-device-mobile:before { content: '\f038'} /*  */ -.octicon-diff:before { content: '\f04d'} /*  */ -.octicon-diff-added:before { content: '\f06b'} /*  */ -.octicon-diff-ignored:before { content: '\f099'} /*  */ -.octicon-diff-modified:before { content: '\f06d'} /*  */ -.octicon-diff-removed:before { content: '\f06c'} /*  */ -.octicon-diff-renamed:before { content: '\f06e'} /*  */ -.octicon-ellipsis:before { content: '\f09a'} /*  */ +.octicon-desktop-download:before { + content: "\f0dc"; +} /*  */ +.octicon-device-camera:before { + content: "\f056"; +} /*  */ +.octicon-device-camera-video:before { + content: "\f057"; +} /*  */ +.octicon-device-desktop:before { + content: "\f27c"; +} /*  */ +.octicon-device-mobile:before { + content: "\f038"; +} /*  */ +.octicon-diff:before { + content: "\f04d"; +} /*  */ +.octicon-diff-added:before { + content: "\f06b"; +} /*  */ +.octicon-diff-ignored:before { + content: "\f099"; +} /*  */ +.octicon-diff-modified:before { + content: "\f06d"; +} /*  */ +.octicon-diff-removed:before { + content: "\f06c"; +} /*  */ +.octicon-diff-renamed:before { + content: "\f06e"; +} /*  */ +.octicon-ellipsis:before { + content: "\f09a"; +} /*  */ .octicon-eye-unwatch:before, .octicon-eye-watch:before, -.octicon-eye:before { content: '\f04e'} /*  */ -.octicon-file-binary:before { content: '\f094'} /*  */ -.octicon-file-code:before { content: '\f010'} /*  */ -.octicon-file-directory:before { content: '\f016'} /*  */ -.octicon-file-media:before { content: '\f012'} /*  */ -.octicon-file-pdf:before { content: '\f014'} /*  */ -.octicon-file-submodule:before { content: '\f017'} /*  */ -.octicon-file-symlink-directory:before { content: '\f0b1'} /*  */ -.octicon-file-symlink-file:before { content: '\f0b0'} /*  */ -.octicon-file-text:before { content: '\f011'} /*  */ -.octicon-file-zip:before { content: '\f013'} /*  */ -.octicon-flame:before { content: '\f0d2'} /*  */ -.octicon-fold:before { content: '\f0cc'} /*  */ -.octicon-gear:before { content: '\f02f'} /*  */ -.octicon-gift:before { content: '\f042'} /*  */ -.octicon-gist:before { content: '\f00e'} /*  */ -.octicon-gist-secret:before { content: '\f08c'} /*  */ +.octicon-eye:before { + content: "\f04e"; +} /*  */ +.octicon-file-binary:before { + content: "\f094"; +} /*  */ +.octicon-file-code:before { + content: "\f010"; +} /*  */ +.octicon-file-directory:before { + content: "\f016"; +} /*  */ +.octicon-file-media:before { + content: "\f012"; +} /*  */ +.octicon-file-pdf:before { + content: "\f014"; +} /*  */ +.octicon-file-submodule:before { + content: "\f017"; +} /*  */ +.octicon-file-symlink-directory:before { + content: "\f0b1"; +} /*  */ +.octicon-file-symlink-file:before { + content: "\f0b0"; +} /*  */ +.octicon-file-text:before { + content: "\f011"; +} /*  */ +.octicon-file-zip:before { + content: "\f013"; +} /*  */ +.octicon-flame:before { + content: "\f0d2"; +} /*  */ +.octicon-fold:before { + content: "\f0cc"; +} /*  */ +.octicon-gear:before { + content: "\f02f"; +} /*  */ +.octicon-gift:before { + content: "\f042"; +} /*  */ +.octicon-gist:before { + content: "\f00e"; +} /*  */ +.octicon-gist-secret:before { + content: "\f08c"; +} /*  */ .octicon-git-branch-create:before, .octicon-git-branch-delete:before, -.octicon-git-branch:before { content: '\f020'} /*  */ -.octicon-git-commit:before { content: '\f01f'} /*  */ -.octicon-git-compare:before { content: '\f0ac'} /*  */ -.octicon-git-merge:before { content: '\f023'} /*  */ +.octicon-git-branch:before { + content: "\f020"; +} /*  */ +.octicon-git-commit:before { + content: "\f01f"; +} /*  */ +.octicon-git-compare:before { + content: "\f0ac"; +} /*  */ +.octicon-git-merge:before { + content: "\f023"; +} /*  */ .octicon-git-pull-request-abandoned:before, -.octicon-git-pull-request:before { content: '\f009'} /*  */ -.octicon-globe:before { content: '\f0b6'} /*  */ -.octicon-graph:before { content: '\f043'} /*  */ -.octicon-heart:before { content: '\2665'} /* ♥ */ -.octicon-history:before { content: '\f07e'} /*  */ -.octicon-home:before { content: '\f08d'} /*  */ -.octicon-horizontal-rule:before { content: '\f070'} /*  */ -.octicon-hubot:before { content: '\f09d'} /*  */ -.octicon-inbox:before { content: '\f0cf'} /*  */ -.octicon-info:before { content: '\f059'} /*  */ -.octicon-issue-closed:before { content: '\f028'} /*  */ -.octicon-issue-opened:before { content: '\f026'} /*  */ -.octicon-issue-reopened:before { content: '\f027'} /*  */ -.octicon-jersey:before { content: '\f019'} /*  */ -.octicon-key:before { content: '\f049'} /*  */ -.octicon-keyboard:before { content: '\f00d'} /*  */ -.octicon-law:before { content: '\f0d8'} /*  */ -.octicon-light-bulb:before { content: '\f000'} /*  */ -.octicon-link:before { content: '\f05c'} /*  */ -.octicon-link-external:before { content: '\f07f'} /*  */ -.octicon-list-ordered:before { content: '\f062'} /*  */ -.octicon-list-unordered:before { content: '\f061'} /*  */ -.octicon-location:before { content: '\f060'} /*  */ +.octicon-git-pull-request:before { + content: "\f009"; +} /*  */ +.octicon-globe:before { + content: "\f0b6"; +} /*  */ +.octicon-graph:before { + content: "\f043"; +} /*  */ +.octicon-heart:before { + content: "\2665"; +} /* ♥ */ +.octicon-history:before { + content: "\f07e"; +} /*  */ +.octicon-home:before { + content: "\f08d"; +} /*  */ +.octicon-horizontal-rule:before { + content: "\f070"; +} /*  */ +.octicon-hubot:before { + content: "\f09d"; +} /*  */ +.octicon-inbox:before { + content: "\f0cf"; +} /*  */ +.octicon-info:before { + content: "\f059"; +} /*  */ +.octicon-issue-closed:before { + content: "\f028"; +} /*  */ +.octicon-issue-opened:before { + content: "\f026"; +} /*  */ +.octicon-issue-reopened:before { + content: "\f027"; +} /*  */ +.octicon-jersey:before { + content: "\f019"; +} /*  */ +.octicon-key:before { + content: "\f049"; +} /*  */ +.octicon-keyboard:before { + content: "\f00d"; +} /*  */ +.octicon-law:before { + content: "\f0d8"; +} /*  */ +.octicon-light-bulb:before { + content: "\f000"; +} /*  */ +.octicon-link:before { + content: "\f05c"; +} /*  */ +.octicon-link-external:before { + content: "\f07f"; +} /*  */ +.octicon-list-ordered:before { + content: "\f062"; +} /*  */ +.octicon-list-unordered:before { + content: "\f061"; +} /*  */ +.octicon-location:before { + content: "\f060"; +} /*  */ .octicon-gist-private:before, .octicon-mirror-private:before, .octicon-git-fork-private:before, -.octicon-lock:before { content: '\f06a'} /*  */ -.octicon-logo-github:before { content: '\f092'} /*  */ -.octicon-mail:before { content: '\f03b'} /*  */ -.octicon-mail-read:before { content: '\f03c'} /*  */ -.octicon-mail-reply:before { content: '\f051'} /*  */ -.octicon-mark-github:before { content: '\f00a'} /*  */ -.octicon-markdown:before { content: '\f0c9'} /*  */ -.octicon-megaphone:before { content: '\f077'} /*  */ -.octicon-mention:before { content: '\f0be'} /*  */ -.octicon-milestone:before { content: '\f075'} /*  */ +.octicon-lock:before { + content: "\f06a"; +} /*  */ +.octicon-logo-github:before { + content: "\f092"; +} /*  */ +.octicon-mail:before { + content: "\f03b"; +} /*  */ +.octicon-mail-read:before { + content: "\f03c"; +} /*  */ +.octicon-mail-reply:before { + content: "\f051"; +} /*  */ +.octicon-mark-github:before { + content: "\f00a"; +} /*  */ +.octicon-markdown:before { + content: "\f0c9"; +} /*  */ +.octicon-megaphone:before { + content: "\f077"; +} /*  */ +.octicon-mention:before { + content: "\f0be"; +} /*  */ +.octicon-milestone:before { + content: "\f075"; +} /*  */ .octicon-mirror-public:before, -.octicon-mirror:before { content: '\f024'} /*  */ -.octicon-mortar-board:before { content: '\f0d7'} /*  */ -.octicon-mute:before { content: '\f080'} /*  */ -.octicon-no-newline:before { content: '\f09c'} /*  */ -.octicon-octoface:before { content: '\f008'} /*  */ -.octicon-organization:before { content: '\f037'} /*  */ -.octicon-package:before { content: '\f0c4'} /*  */ -.octicon-paintcan:before { content: '\f0d1'} /*  */ -.octicon-pencil:before { content: '\f058'} /*  */ +.octicon-mirror:before { + content: "\f024"; +} /*  */ +.octicon-mortar-board:before { + content: "\f0d7"; +} /*  */ +.octicon-mute:before { + content: "\f080"; +} /*  */ +.octicon-no-newline:before { + content: "\f09c"; +} /*  */ +.octicon-octoface:before { + content: "\f008"; +} /*  */ +.octicon-organization:before { + content: "\f037"; +} /*  */ +.octicon-package:before { + content: "\f0c4"; +} /*  */ +.octicon-paintcan:before { + content: "\f0d1"; +} /*  */ +.octicon-pencil:before { + content: "\f058"; +} /*  */ .octicon-person-add:before, .octicon-person-follow:before, -.octicon-person:before { content: '\f018'} /*  */ -.octicon-pin:before { content: '\f041'} /*  */ -.octicon-plug:before { content: '\f0d4'} /*  */ +.octicon-person:before { + content: "\f018"; +} /*  */ +.octicon-pin:before { + content: "\f041"; +} /*  */ +.octicon-plug:before { + content: "\f0d4"; +} /*  */ .octicon-repo-create:before, .octicon-gist-new:before, .octicon-file-directory-create:before, .octicon-file-add:before, -.octicon-plus:before { content: '\f05d'} /*  */ -.octicon-primitive-dot:before { content: '\f052'} /*  */ -.octicon-primitive-square:before { content: '\f053'} /*  */ -.octicon-pulse:before { content: '\f085'} /*  */ -.octicon-question:before { content: '\f02c'} /*  */ -.octicon-quote:before { content: '\f063'} /*  */ -.octicon-radio-tower:before { content: '\f030'} /*  */ +.octicon-plus:before { + content: "\f05d"; +} /*  */ +.octicon-primitive-dot:before { + content: "\f052"; +} /*  */ +.octicon-primitive-square:before { + content: "\f053"; +} /*  */ +.octicon-pulse:before { + content: "\f085"; +} /*  */ +.octicon-question:before { + content: "\f02c"; +} /*  */ +.octicon-quote:before { + content: "\f063"; +} /*  */ +.octicon-radio-tower:before { + content: "\f030"; +} /*  */ .octicon-repo-delete:before, -.octicon-repo:before { content: '\f001'} /*  */ -.octicon-repo-clone:before { content: '\f04c'} /*  */ -.octicon-repo-force-push:before { content: '\f04a'} /*  */ +.octicon-repo:before { + content: "\f001"; +} /*  */ +.octicon-repo-clone:before { + content: "\f04c"; +} /*  */ +.octicon-repo-force-push:before { + content: "\f04a"; +} /*  */ .octicon-gist-fork:before, -.octicon-repo-forked:before { content: '\f002'} /*  */ -.octicon-repo-pull:before { content: '\f006'} /*  */ -.octicon-repo-push:before { content: '\f005'} /*  */ -.octicon-rocket:before { content: '\f033'} /*  */ -.octicon-rss:before { content: '\f034'} /*  */ -.octicon-ruby:before { content: '\f047'} /*  */ -.octicon-screen-full:before { content: '\f066'} /*  */ -.octicon-screen-normal:before { content: '\f067'} /*  */ +.octicon-repo-forked:before { + content: "\f002"; +} /*  */ +.octicon-repo-pull:before { + content: "\f006"; +} /*  */ +.octicon-repo-push:before { + content: "\f005"; +} /*  */ +.octicon-rocket:before { + content: "\f033"; +} /*  */ +.octicon-rss:before { + content: "\f034"; +} /*  */ +.octicon-ruby:before { + content: "\f047"; +} /*  */ +.octicon-screen-full:before { + content: "\f066"; +} /*  */ +.octicon-screen-normal:before { + content: "\f067"; +} /*  */ .octicon-search-save:before, -.octicon-search:before { content: '\f02e'} /*  */ -.octicon-server:before { content: '\f097'} /*  */ -.octicon-settings:before { content: '\f07c'} /*  */ -.octicon-shield:before { content: '\f0e1'} /*  */ +.octicon-search:before { + content: "\f02e"; +} /*  */ +.octicon-server:before { + content: "\f097"; +} /*  */ +.octicon-settings:before { + content: "\f07c"; +} /*  */ +.octicon-shield:before { + content: "\f0e1"; +} /*  */ .octicon-log-in:before, -.octicon-sign-in:before { content: '\f036'} /*  */ +.octicon-sign-in:before { + content: "\f036"; +} /*  */ .octicon-log-out:before, -.octicon-sign-out:before { content: '\f032'} /*  */ -.octicon-squirrel:before { content: '\f0b2'} /*  */ +.octicon-sign-out:before { + content: "\f032"; +} /*  */ +.octicon-squirrel:before { + content: "\f0b2"; +} /*  */ .octicon-star-add:before, .octicon-star-delete:before, -.octicon-star:before { content: '\f02a'} /*  */ -.octicon-stop:before { content: '\f08f'} /*  */ +.octicon-star:before { + content: "\f02a"; +} /*  */ +.octicon-stop:before { + content: "\f08f"; +} /*  */ .octicon-repo-sync:before, -.octicon-sync:before { content: '\f087'} /*  */ +.octicon-sync:before { + content: "\f087"; +} /*  */ .octicon-tag-remove:before, .octicon-tag-add:before, -.octicon-tag:before { content: '\f015'} /*  */ -.octicon-telescope:before { content: '\f088'} /*  */ -.octicon-terminal:before { content: '\f0c8'} /*  */ -.octicon-three-bars:before { content: '\f05e'} /*  */ -.octicon-thumbsdown:before { content: '\f0db'} /*  */ -.octicon-thumbsup:before { content: '\f0da'} /*  */ -.octicon-tools:before { content: '\f031'} /*  */ -.octicon-trashcan:before { content: '\f0d0'} /*  */ -.octicon-triangle-down:before { content: '\f05b'} /*  */ -.octicon-triangle-left:before { content: '\f044'} /*  */ -.octicon-triangle-right:before { content: '\f05a'} /*  */ -.octicon-triangle-up:before { content: '\f0aa'} /*  */ -.octicon-unfold:before { content: '\f039'} /*  */ -.octicon-unmute:before { content: '\f0ba'} /*  */ -.octicon-versions:before { content: '\f064'} /*  */ -.octicon-watch:before { content: '\f0e0'} /*  */ +.octicon-tag:before { + content: "\f015"; +} /*  */ +.octicon-telescope:before { + content: "\f088"; +} /*  */ +.octicon-terminal:before { + content: "\f0c8"; +} /*  */ +.octicon-three-bars:before { + content: "\f05e"; +} /*  */ +.octicon-thumbsdown:before { + content: "\f0db"; +} /*  */ +.octicon-thumbsup:before { + content: "\f0da"; +} /*  */ +.octicon-tools:before { + content: "\f031"; +} /*  */ +.octicon-trashcan:before { + content: "\f0d0"; +} /*  */ +.octicon-triangle-down:before { + content: "\f05b"; +} /*  */ +.octicon-triangle-left:before { + content: "\f044"; +} /*  */ +.octicon-triangle-right:before { + content: "\f05a"; +} /*  */ +.octicon-triangle-up:before { + content: "\f0aa"; +} /*  */ +.octicon-unfold:before { + content: "\f039"; +} /*  */ +.octicon-unmute:before { + content: "\f0ba"; +} /*  */ +.octicon-versions:before { + content: "\f064"; +} /*  */ +.octicon-watch:before { + content: "\f0e0"; +} /*  */ .octicon-remove-close:before, -.octicon-x:before { content: '\f081'} /*  */ -.octicon-zap:before { content: '\26A1'} /* ⚡ */ +.octicon-x:before { + content: "\f081"; +} /*  */ +.octicon-zap:before { + content: "\26A1"; +} /* ⚡ */ diff --git a/frappe/public/css/octicons/sprockets-octicons.scss b/frappe/public/css/octicons/sprockets-octicons.scss index cef21ae62e..84cd64d260 100755 --- a/frappe/public/css/octicons/sprockets-octicons.scss +++ b/frappe/public/css/octicons/sprockets-octicons.scss @@ -1,217 +1,543 @@ @font-face { - font-family: 'octicons'; - src: font-url('octicons.eot?#iefix') format('embedded-opentype'), - font-url('octicons.woff') format('woff'), - font-url('octicons.ttf') format('truetype'), - font-url('octicons.svg#octicons') format('svg'); - font-weight: normal; - font-style: normal; + font-family: "octicons"; + src: font-url("octicons.eot?#iefix") format("embedded-opentype"), + font-url("octicons.woff") format("woff"), font-url("octicons.ttf") format("truetype"), + font-url("octicons.svg#octicons") format("svg"); + font-weight: normal; + font-style: normal; } // .octicon is optimized for 16px. // .mega-octicon is optimized for 32px but can be used larger. -.octicon, .mega-octicon { - font: normal normal normal 16px/1 octicons; - display: inline-block; - text-decoration: none; - text-rendering: auto; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; +.octicon, +.mega-octicon { + font: normal normal normal 16px/1 octicons; + display: inline-block; + text-decoration: none; + text-rendering: auto; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} +.mega-octicon { + font-size: 32px; } -.mega-octicon { font-size: 32px; } -.octicon-alert:before { content: '\f02d'} /*  */ -.octicon-arrow-down:before { content: '\f03f'} /*  */ -.octicon-arrow-left:before { content: '\f040'} /*  */ -.octicon-arrow-right:before { content: '\f03e'} /*  */ -.octicon-arrow-small-down:before { content: '\f0a0'} /*  */ -.octicon-arrow-small-left:before { content: '\f0a1'} /*  */ -.octicon-arrow-small-right:before { content: '\f071'} /*  */ -.octicon-arrow-small-up:before { content: '\f09f'} /*  */ -.octicon-arrow-up:before { content: '\f03d'} /*  */ +.octicon-alert:before { + content: "\f02d"; +} /*  */ +.octicon-arrow-down:before { + content: "\f03f"; +} /*  */ +.octicon-arrow-left:before { + content: "\f040"; +} /*  */ +.octicon-arrow-right:before { + content: "\f03e"; +} /*  */ +.octicon-arrow-small-down:before { + content: "\f0a0"; +} /*  */ +.octicon-arrow-small-left:before { + content: "\f0a1"; +} /*  */ +.octicon-arrow-small-right:before { + content: "\f071"; +} /*  */ +.octicon-arrow-small-up:before { + content: "\f09f"; +} /*  */ +.octicon-arrow-up:before { + content: "\f03d"; +} /*  */ .octicon-microscope:before, -.octicon-beaker:before { content: '\f0dd'} /*  */ -.octicon-bell:before { content: '\f0de'} /*  */ -.octicon-book:before { content: '\f007'} /*  */ -.octicon-bookmark:before { content: '\f07b'} /*  */ -.octicon-briefcase:before { content: '\f0d3'} /*  */ -.octicon-broadcast:before { content: '\f048'} /*  */ -.octicon-browser:before { content: '\f0c5'} /*  */ -.octicon-bug:before { content: '\f091'} /*  */ -.octicon-calendar:before { content: '\f068'} /*  */ -.octicon-check:before { content: '\f03a'} /*  */ -.octicon-checklist:before { content: '\f076'} /*  */ -.octicon-chevron-down:before { content: '\f0a3'} /*  */ -.octicon-chevron-left:before { content: '\f0a4'} /*  */ -.octicon-chevron-right:before { content: '\f078'} /*  */ -.octicon-chevron-up:before { content: '\f0a2'} /*  */ -.octicon-circle-slash:before { content: '\f084'} /*  */ -.octicon-circuit-board:before { content: '\f0d6'} /*  */ -.octicon-clippy:before { content: '\f035'} /*  */ -.octicon-clock:before { content: '\f046'} /*  */ -.octicon-cloud-download:before { content: '\f00b'} /*  */ -.octicon-cloud-upload:before { content: '\f00c'} /*  */ -.octicon-code:before { content: '\f05f'} /*  */ -.octicon-color-mode:before { content: '\f065'} /*  */ +.octicon-beaker:before { + content: "\f0dd"; +} /*  */ +.octicon-bell:before { + content: "\f0de"; +} /*  */ +.octicon-book:before { + content: "\f007"; +} /*  */ +.octicon-bookmark:before { + content: "\f07b"; +} /*  */ +.octicon-briefcase:before { + content: "\f0d3"; +} /*  */ +.octicon-broadcast:before { + content: "\f048"; +} /*  */ +.octicon-browser:before { + content: "\f0c5"; +} /*  */ +.octicon-bug:before { + content: "\f091"; +} /*  */ +.octicon-calendar:before { + content: "\f068"; +} /*  */ +.octicon-check:before { + content: "\f03a"; +} /*  */ +.octicon-checklist:before { + content: "\f076"; +} /*  */ +.octicon-chevron-down:before { + content: "\f0a3"; +} /*  */ +.octicon-chevron-left:before { + content: "\f0a4"; +} /*  */ +.octicon-chevron-right:before { + content: "\f078"; +} /*  */ +.octicon-chevron-up:before { + content: "\f0a2"; +} /*  */ +.octicon-circle-slash:before { + content: "\f084"; +} /*  */ +.octicon-circuit-board:before { + content: "\f0d6"; +} /*  */ +.octicon-clippy:before { + content: "\f035"; +} /*  */ +.octicon-clock:before { + content: "\f046"; +} /*  */ +.octicon-cloud-download:before { + content: "\f00b"; +} /*  */ +.octicon-cloud-upload:before { + content: "\f00c"; +} /*  */ +.octicon-code:before { + content: "\f05f"; +} /*  */ +.octicon-color-mode:before { + content: "\f065"; +} /*  */ .octicon-comment-add:before, -.octicon-comment:before { content: '\f02b'} /*  */ -.octicon-comment-discussion:before { content: '\f04f'} /*  */ -.octicon-credit-card:before { content: '\f045'} /*  */ -.octicon-dash:before { content: '\f0ca'} /*  */ -.octicon-dashboard:before { content: '\f07d'} /*  */ -.octicon-database:before { content: '\f096'} /*  */ +.octicon-comment:before { + content: "\f02b"; +} /*  */ +.octicon-comment-discussion:before { + content: "\f04f"; +} /*  */ +.octicon-credit-card:before { + content: "\f045"; +} /*  */ +.octicon-dash:before { + content: "\f0ca"; +} /*  */ +.octicon-dashboard:before { + content: "\f07d"; +} /*  */ +.octicon-database:before { + content: "\f096"; +} /*  */ .octicon-clone:before, -.octicon-desktop-download:before { content: '\f0dc'} /*  */ -.octicon-device-camera:before { content: '\f056'} /*  */ -.octicon-device-camera-video:before { content: '\f057'} /*  */ -.octicon-device-desktop:before { content: '\f27c'} /*  */ -.octicon-device-mobile:before { content: '\f038'} /*  */ -.octicon-diff:before { content: '\f04d'} /*  */ -.octicon-diff-added:before { content: '\f06b'} /*  */ -.octicon-diff-ignored:before { content: '\f099'} /*  */ -.octicon-diff-modified:before { content: '\f06d'} /*  */ -.octicon-diff-removed:before { content: '\f06c'} /*  */ -.octicon-diff-renamed:before { content: '\f06e'} /*  */ -.octicon-ellipsis:before { content: '\f09a'} /*  */ +.octicon-desktop-download:before { + content: "\f0dc"; +} /*  */ +.octicon-device-camera:before { + content: "\f056"; +} /*  */ +.octicon-device-camera-video:before { + content: "\f057"; +} /*  */ +.octicon-device-desktop:before { + content: "\f27c"; +} /*  */ +.octicon-device-mobile:before { + content: "\f038"; +} /*  */ +.octicon-diff:before { + content: "\f04d"; +} /*  */ +.octicon-diff-added:before { + content: "\f06b"; +} /*  */ +.octicon-diff-ignored:before { + content: "\f099"; +} /*  */ +.octicon-diff-modified:before { + content: "\f06d"; +} /*  */ +.octicon-diff-removed:before { + content: "\f06c"; +} /*  */ +.octicon-diff-renamed:before { + content: "\f06e"; +} /*  */ +.octicon-ellipsis:before { + content: "\f09a"; +} /*  */ .octicon-eye-unwatch:before, .octicon-eye-watch:before, -.octicon-eye:before { content: '\f04e'} /*  */ -.octicon-file-binary:before { content: '\f094'} /*  */ -.octicon-file-code:before { content: '\f010'} /*  */ -.octicon-file-directory:before { content: '\f016'} /*  */ -.octicon-file-media:before { content: '\f012'} /*  */ -.octicon-file-pdf:before { content: '\f014'} /*  */ -.octicon-file-submodule:before { content: '\f017'} /*  */ -.octicon-file-symlink-directory:before { content: '\f0b1'} /*  */ -.octicon-file-symlink-file:before { content: '\f0b0'} /*  */ -.octicon-file-text:before { content: '\f011'} /*  */ -.octicon-file-zip:before { content: '\f013'} /*  */ -.octicon-flame:before { content: '\f0d2'} /*  */ -.octicon-fold:before { content: '\f0cc'} /*  */ -.octicon-gear:before { content: '\f02f'} /*  */ -.octicon-gift:before { content: '\f042'} /*  */ -.octicon-gist:before { content: '\f00e'} /*  */ -.octicon-gist-secret:before { content: '\f08c'} /*  */ +.octicon-eye:before { + content: "\f04e"; +} /*  */ +.octicon-file-binary:before { + content: "\f094"; +} /*  */ +.octicon-file-code:before { + content: "\f010"; +} /*  */ +.octicon-file-directory:before { + content: "\f016"; +} /*  */ +.octicon-file-media:before { + content: "\f012"; +} /*  */ +.octicon-file-pdf:before { + content: "\f014"; +} /*  */ +.octicon-file-submodule:before { + content: "\f017"; +} /*  */ +.octicon-file-symlink-directory:before { + content: "\f0b1"; +} /*  */ +.octicon-file-symlink-file:before { + content: "\f0b0"; +} /*  */ +.octicon-file-text:before { + content: "\f011"; +} /*  */ +.octicon-file-zip:before { + content: "\f013"; +} /*  */ +.octicon-flame:before { + content: "\f0d2"; +} /*  */ +.octicon-fold:before { + content: "\f0cc"; +} /*  */ +.octicon-gear:before { + content: "\f02f"; +} /*  */ +.octicon-gift:before { + content: "\f042"; +} /*  */ +.octicon-gist:before { + content: "\f00e"; +} /*  */ +.octicon-gist-secret:before { + content: "\f08c"; +} /*  */ .octicon-git-branch-create:before, .octicon-git-branch-delete:before, -.octicon-git-branch:before { content: '\f020'} /*  */ -.octicon-git-commit:before { content: '\f01f'} /*  */ -.octicon-git-compare:before { content: '\f0ac'} /*  */ -.octicon-git-merge:before { content: '\f023'} /*  */ +.octicon-git-branch:before { + content: "\f020"; +} /*  */ +.octicon-git-commit:before { + content: "\f01f"; +} /*  */ +.octicon-git-compare:before { + content: "\f0ac"; +} /*  */ +.octicon-git-merge:before { + content: "\f023"; +} /*  */ .octicon-git-pull-request-abandoned:before, -.octicon-git-pull-request:before { content: '\f009'} /*  */ -.octicon-globe:before { content: '\f0b6'} /*  */ -.octicon-graph:before { content: '\f043'} /*  */ -.octicon-heart:before { content: '\2665'} /* ♥ */ -.octicon-history:before { content: '\f07e'} /*  */ -.octicon-home:before { content: '\f08d'} /*  */ -.octicon-horizontal-rule:before { content: '\f070'} /*  */ -.octicon-hubot:before { content: '\f09d'} /*  */ -.octicon-inbox:before { content: '\f0cf'} /*  */ -.octicon-info:before { content: '\f059'} /*  */ -.octicon-issue-closed:before { content: '\f028'} /*  */ -.octicon-issue-opened:before { content: '\f026'} /*  */ -.octicon-issue-reopened:before { content: '\f027'} /*  */ -.octicon-jersey:before { content: '\f019'} /*  */ -.octicon-key:before { content: '\f049'} /*  */ -.octicon-keyboard:before { content: '\f00d'} /*  */ -.octicon-law:before { content: '\f0d8'} /*  */ -.octicon-light-bulb:before { content: '\f000'} /*  */ -.octicon-link:before { content: '\f05c'} /*  */ -.octicon-link-external:before { content: '\f07f'} /*  */ -.octicon-list-ordered:before { content: '\f062'} /*  */ -.octicon-list-unordered:before { content: '\f061'} /*  */ -.octicon-location:before { content: '\f060'} /*  */ +.octicon-git-pull-request:before { + content: "\f009"; +} /*  */ +.octicon-globe:before { + content: "\f0b6"; +} /*  */ +.octicon-graph:before { + content: "\f043"; +} /*  */ +.octicon-heart:before { + content: "\2665"; +} /* ♥ */ +.octicon-history:before { + content: "\f07e"; +} /*  */ +.octicon-home:before { + content: "\f08d"; +} /*  */ +.octicon-horizontal-rule:before { + content: "\f070"; +} /*  */ +.octicon-hubot:before { + content: "\f09d"; +} /*  */ +.octicon-inbox:before { + content: "\f0cf"; +} /*  */ +.octicon-info:before { + content: "\f059"; +} /*  */ +.octicon-issue-closed:before { + content: "\f028"; +} /*  */ +.octicon-issue-opened:before { + content: "\f026"; +} /*  */ +.octicon-issue-reopened:before { + content: "\f027"; +} /*  */ +.octicon-jersey:before { + content: "\f019"; +} /*  */ +.octicon-key:before { + content: "\f049"; +} /*  */ +.octicon-keyboard:before { + content: "\f00d"; +} /*  */ +.octicon-law:before { + content: "\f0d8"; +} /*  */ +.octicon-light-bulb:before { + content: "\f000"; +} /*  */ +.octicon-link:before { + content: "\f05c"; +} /*  */ +.octicon-link-external:before { + content: "\f07f"; +} /*  */ +.octicon-list-ordered:before { + content: "\f062"; +} /*  */ +.octicon-list-unordered:before { + content: "\f061"; +} /*  */ +.octicon-location:before { + content: "\f060"; +} /*  */ .octicon-gist-private:before, .octicon-mirror-private:before, .octicon-git-fork-private:before, -.octicon-lock:before { content: '\f06a'} /*  */ -.octicon-logo-github:before { content: '\f092'} /*  */ -.octicon-mail:before { content: '\f03b'} /*  */ -.octicon-mail-read:before { content: '\f03c'} /*  */ -.octicon-mail-reply:before { content: '\f051'} /*  */ -.octicon-mark-github:before { content: '\f00a'} /*  */ -.octicon-markdown:before { content: '\f0c9'} /*  */ -.octicon-megaphone:before { content: '\f077'} /*  */ -.octicon-mention:before { content: '\f0be'} /*  */ -.octicon-milestone:before { content: '\f075'} /*  */ +.octicon-lock:before { + content: "\f06a"; +} /*  */ +.octicon-logo-github:before { + content: "\f092"; +} /*  */ +.octicon-mail:before { + content: "\f03b"; +} /*  */ +.octicon-mail-read:before { + content: "\f03c"; +} /*  */ +.octicon-mail-reply:before { + content: "\f051"; +} /*  */ +.octicon-mark-github:before { + content: "\f00a"; +} /*  */ +.octicon-markdown:before { + content: "\f0c9"; +} /*  */ +.octicon-megaphone:before { + content: "\f077"; +} /*  */ +.octicon-mention:before { + content: "\f0be"; +} /*  */ +.octicon-milestone:before { + content: "\f075"; +} /*  */ .octicon-mirror-public:before, -.octicon-mirror:before { content: '\f024'} /*  */ -.octicon-mortar-board:before { content: '\f0d7'} /*  */ -.octicon-mute:before { content: '\f080'} /*  */ -.octicon-no-newline:before { content: '\f09c'} /*  */ -.octicon-octoface:before { content: '\f008'} /*  */ -.octicon-organization:before { content: '\f037'} /*  */ -.octicon-package:before { content: '\f0c4'} /*  */ -.octicon-paintcan:before { content: '\f0d1'} /*  */ -.octicon-pencil:before { content: '\f058'} /*  */ +.octicon-mirror:before { + content: "\f024"; +} /*  */ +.octicon-mortar-board:before { + content: "\f0d7"; +} /*  */ +.octicon-mute:before { + content: "\f080"; +} /*  */ +.octicon-no-newline:before { + content: "\f09c"; +} /*  */ +.octicon-octoface:before { + content: "\f008"; +} /*  */ +.octicon-organization:before { + content: "\f037"; +} /*  */ +.octicon-package:before { + content: "\f0c4"; +} /*  */ +.octicon-paintcan:before { + content: "\f0d1"; +} /*  */ +.octicon-pencil:before { + content: "\f058"; +} /*  */ .octicon-person-add:before, .octicon-person-follow:before, -.octicon-person:before { content: '\f018'} /*  */ -.octicon-pin:before { content: '\f041'} /*  */ -.octicon-plug:before { content: '\f0d4'} /*  */ +.octicon-person:before { + content: "\f018"; +} /*  */ +.octicon-pin:before { + content: "\f041"; +} /*  */ +.octicon-plug:before { + content: "\f0d4"; +} /*  */ .octicon-repo-create:before, .octicon-gist-new:before, .octicon-file-directory-create:before, .octicon-file-add:before, -.octicon-plus:before { content: '\f05d'} /*  */ -.octicon-primitive-dot:before { content: '\f052'} /*  */ -.octicon-primitive-square:before { content: '\f053'} /*  */ -.octicon-pulse:before { content: '\f085'} /*  */ -.octicon-question:before { content: '\f02c'} /*  */ -.octicon-quote:before { content: '\f063'} /*  */ -.octicon-radio-tower:before { content: '\f030'} /*  */ +.octicon-plus:before { + content: "\f05d"; +} /*  */ +.octicon-primitive-dot:before { + content: "\f052"; +} /*  */ +.octicon-primitive-square:before { + content: "\f053"; +} /*  */ +.octicon-pulse:before { + content: "\f085"; +} /*  */ +.octicon-question:before { + content: "\f02c"; +} /*  */ +.octicon-quote:before { + content: "\f063"; +} /*  */ +.octicon-radio-tower:before { + content: "\f030"; +} /*  */ .octicon-repo-delete:before, -.octicon-repo:before { content: '\f001'} /*  */ -.octicon-repo-clone:before { content: '\f04c'} /*  */ -.octicon-repo-force-push:before { content: '\f04a'} /*  */ +.octicon-repo:before { + content: "\f001"; +} /*  */ +.octicon-repo-clone:before { + content: "\f04c"; +} /*  */ +.octicon-repo-force-push:before { + content: "\f04a"; +} /*  */ .octicon-gist-fork:before, -.octicon-repo-forked:before { content: '\f002'} /*  */ -.octicon-repo-pull:before { content: '\f006'} /*  */ -.octicon-repo-push:before { content: '\f005'} /*  */ -.octicon-rocket:before { content: '\f033'} /*  */ -.octicon-rss:before { content: '\f034'} /*  */ -.octicon-ruby:before { content: '\f047'} /*  */ -.octicon-screen-full:before { content: '\f066'} /*  */ -.octicon-screen-normal:before { content: '\f067'} /*  */ +.octicon-repo-forked:before { + content: "\f002"; +} /*  */ +.octicon-repo-pull:before { + content: "\f006"; +} /*  */ +.octicon-repo-push:before { + content: "\f005"; +} /*  */ +.octicon-rocket:before { + content: "\f033"; +} /*  */ +.octicon-rss:before { + content: "\f034"; +} /*  */ +.octicon-ruby:before { + content: "\f047"; +} /*  */ +.octicon-screen-full:before { + content: "\f066"; +} /*  */ +.octicon-screen-normal:before { + content: "\f067"; +} /*  */ .octicon-search-save:before, -.octicon-search:before { content: '\f02e'} /*  */ -.octicon-server:before { content: '\f097'} /*  */ -.octicon-settings:before { content: '\f07c'} /*  */ -.octicon-shield:before { content: '\f0e1'} /*  */ +.octicon-search:before { + content: "\f02e"; +} /*  */ +.octicon-server:before { + content: "\f097"; +} /*  */ +.octicon-settings:before { + content: "\f07c"; +} /*  */ +.octicon-shield:before { + content: "\f0e1"; +} /*  */ .octicon-log-in:before, -.octicon-sign-in:before { content: '\f036'} /*  */ +.octicon-sign-in:before { + content: "\f036"; +} /*  */ .octicon-log-out:before, -.octicon-sign-out:before { content: '\f032'} /*  */ -.octicon-squirrel:before { content: '\f0b2'} /*  */ +.octicon-sign-out:before { + content: "\f032"; +} /*  */ +.octicon-squirrel:before { + content: "\f0b2"; +} /*  */ .octicon-star-add:before, .octicon-star-delete:before, -.octicon-star:before { content: '\f02a'} /*  */ -.octicon-stop:before { content: '\f08f'} /*  */ +.octicon-star:before { + content: "\f02a"; +} /*  */ +.octicon-stop:before { + content: "\f08f"; +} /*  */ .octicon-repo-sync:before, -.octicon-sync:before { content: '\f087'} /*  */ +.octicon-sync:before { + content: "\f087"; +} /*  */ .octicon-tag-remove:before, .octicon-tag-add:before, -.octicon-tag:before { content: '\f015'} /*  */ -.octicon-telescope:before { content: '\f088'} /*  */ -.octicon-terminal:before { content: '\f0c8'} /*  */ -.octicon-three-bars:before { content: '\f05e'} /*  */ -.octicon-thumbsdown:before { content: '\f0db'} /*  */ -.octicon-thumbsup:before { content: '\f0da'} /*  */ -.octicon-tools:before { content: '\f031'} /*  */ -.octicon-trashcan:before { content: '\f0d0'} /*  */ -.octicon-triangle-down:before { content: '\f05b'} /*  */ -.octicon-triangle-left:before { content: '\f044'} /*  */ -.octicon-triangle-right:before { content: '\f05a'} /*  */ -.octicon-triangle-up:before { content: '\f0aa'} /*  */ -.octicon-unfold:before { content: '\f039'} /*  */ -.octicon-unmute:before { content: '\f0ba'} /*  */ -.octicon-versions:before { content: '\f064'} /*  */ -.octicon-watch:before { content: '\f0e0'} /*  */ +.octicon-tag:before { + content: "\f015"; +} /*  */ +.octicon-telescope:before { + content: "\f088"; +} /*  */ +.octicon-terminal:before { + content: "\f0c8"; +} /*  */ +.octicon-three-bars:before { + content: "\f05e"; +} /*  */ +.octicon-thumbsdown:before { + content: "\f0db"; +} /*  */ +.octicon-thumbsup:before { + content: "\f0da"; +} /*  */ +.octicon-tools:before { + content: "\f031"; +} /*  */ +.octicon-trashcan:before { + content: "\f0d0"; +} /*  */ +.octicon-triangle-down:before { + content: "\f05b"; +} /*  */ +.octicon-triangle-left:before { + content: "\f044"; +} /*  */ +.octicon-triangle-right:before { + content: "\f05a"; +} /*  */ +.octicon-triangle-up:before { + content: "\f0aa"; +} /*  */ +.octicon-unfold:before { + content: "\f039"; +} /*  */ +.octicon-unmute:before { + content: "\f0ba"; +} /*  */ +.octicon-versions:before { + content: "\f064"; +} /*  */ +.octicon-watch:before { + content: "\f0e0"; +} /*  */ .octicon-remove-close:before, -.octicon-x:before { content: '\f081'} /*  */ -.octicon-zap:before { content: '\26A1'} /* ⚡ */ +.octicon-x:before { + content: "\f081"; +} /*  */ +.octicon-zap:before { + content: "\26A1"; +} /* ⚡ */ diff --git a/frappe/public/icons/timeless/icons.svg b/frappe/public/icons/timeless/icons.svg index 856e743bc2..58665bd20b 100644 --- a/frappe/public/icons/timeless/icons.svg +++ b/frappe/public/icons/timeless/icons.svg @@ -815,7 +815,7 @@ + stroke="none" fill="var(--icon-stroke)"> diff --git a/frappe/public/js/form_builder/FormBuilder.vue b/frappe/public/js/form_builder/FormBuilder.vue index 427b93584b..63ed4ba25d 100644 --- a/frappe/public/js/form_builder/FormBuilder.vue +++ b/frappe/public/js/form_builder/FormBuilder.vue @@ -12,7 +12,9 @@ let should_render = computed(() => { }); let container = ref(null); -onClickOutside(container, () => (store.form.selected_field = null)); +onClickOutside(container, () => (store.form.selected_field = null), { + ignore: [".combo-box-options"], +}); watch( () => store.form.layout, @@ -30,22 +32,23 @@ onMounted(() => store.fetch()); class="form-builder-container" @click="store.form.selected_field = null" > -
-
- -
-
+
+
+ +
+
+
diff --git a/frappe/public/js/form_builder/components/AddFieldButton.vue b/frappe/public/js/form_builder/components/AddFieldButton.vue new file mode 100644 index 0000000000..92a141e4e6 --- /dev/null +++ b/frappe/public/js/form_builder/components/AddFieldButton.vue @@ -0,0 +1,139 @@ + + + + + diff --git a/frappe/public/js/form_builder/components/Autocomplete.vue b/frappe/public/js/form_builder/components/Autocomplete.vue new file mode 100644 index 0000000000..3bbaf8b298 --- /dev/null +++ b/frappe/public/js/form_builder/components/Autocomplete.vue @@ -0,0 +1,160 @@ + + + + + diff --git a/frappe/public/js/form_builder/components/Column.vue b/frappe/public/js/form_builder/components/Column.vue index 54dd49d5bf..3fbc67e115 100644 --- a/frappe/public/js/form_builder/components/Column.vue +++ b/frappe/public/js/form_builder/components/Column.vue @@ -1,143 +1,18 @@ - - +
+ +
+
+ +
+ + diff --git a/frappe/public/js/form_builder/components/Dropdown.vue b/frappe/public/js/form_builder/components/Dropdown.vue new file mode 100644 index 0000000000..23e1a0443d --- /dev/null +++ b/frappe/public/js/form_builder/components/Dropdown.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/frappe/public/js/form_builder/components/EditableInput.vue b/frappe/public/js/form_builder/components/EditableInput.vue index 21b517af3b..34593460ee 100644 --- a/frappe/public/js/form_builder/components/EditableInput.vue +++ b/frappe/public/js/form_builder/components/EditableInput.vue @@ -5,13 +5,13 @@ let store = useStore(); const props = defineProps({ text: { - type: String + type: String, }, placeholder: { - default: __("No Label") + default: __("No Label"), }, empty_label: { - default: __("No Label") + default: __("No Label"), }, }); @@ -35,6 +35,8 @@ function focus_on_label() { nextTick(() => input_text.value.focus()); } } + +defineExpose({ focus_on_label });